diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 0d90cecea3..95ff65f7d2 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -49,6 +49,7 @@ import ( mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" // Task Service packages + analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" taskcontroller "github.com/kandev/kandev/internal/task/controller" taskhandlers "github.com/kandev/kandev/internal/task/handlers" taskservice "github.com/kandev/kandev/internal/task/service" @@ -193,6 +194,7 @@ func main() { log.Fatal("Failed to initialize services", zap.Error(err)) } taskRepo := repos.Task + analyticsRepo := repos.Analytics agentSettingsRepo := repos.AgentSettings notificationRepo := repos.Notification taskSvc := services.Task @@ -534,6 +536,7 @@ func main() { log, ) taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log) + analyticshandlers.RegisterStatsRoutes(router, analyticsRepo, log) agenthandlers.RegisterShellRoutes(router, lifecycleMgr, log) log.Debug("Registered Task Service handlers (HTTP + WebSocket)") @@ -568,9 +571,9 @@ func main() { workflowCtrl, clarificationStore, msgCreator, - taskRepo, // SessionRepository - taskRepo, // TaskRepository (same instance) - eventBus, // EventBus + taskRepo, // SessionRepository + taskRepo, // TaskRepository (same instance) + eventBus, // EventBus planService, log, ) diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 065f0c7c85..ffce8484e4 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -3,6 +3,7 @@ package main import ( "database/sql" + analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/persistence" @@ -30,6 +31,12 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo } cleanups = append(cleanups, cleanup) + analyticsRepo, cleanup, err := analyticsrepository.Provide(dbConn) + if err != nil { + return nil, nil, nil, err + } + cleanups = append(cleanups, cleanup) + agentSettingsRepo, cleanup, err := settingsstore.Provide(dbConn) if err != nil { return nil, nil, nil, err @@ -67,6 +74,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo repos := &Repositories{ Task: taskRepoImpl, + Analytics: analyticsRepo, AgentSettings: agentSettingsRepo, User: userRepo, Notification: notificationRepo, diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 236664b14d..d226035778 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -2,6 +2,7 @@ package main import ( settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" notificationservice "github.com/kandev/kandev/internal/notifications/service" @@ -18,6 +19,7 @@ import ( type Repositories struct { Task repository.Repository + Analytics analyticsrepository.Repository AgentSettings settingsstore.Repository User userstore.Repository Notification notificationstore.Repository diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index 75c92c856e..1299284b8f 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -840,7 +840,7 @@ func TestHandleAgentEvent_CompleteWithoutStreaming(t *testing.T) { } // TestHandleAgentEvent_CompleteWithBufferedText verifies that buffered text -// without streaming is emitted on complete for persistence. +// without streaming is emitted as a final streaming event on complete. func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) { mgr, eventBus := createTestManagerWithTracking() execution := createTestExecution("exec-1", "task-1", "session-1") @@ -852,7 +852,7 @@ func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) { Text: "Final message without newline", }) - // Complete event should flush buffer into complete text + // Complete event should flush buffer into a streaming event mgr.handleAgentEvent(execution, agentctl.AgentEvent{ Type: "complete", }) @@ -876,12 +876,14 @@ func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) { t.Errorf("expected 1 complete event, got %d", len(completeEvents)) } - if len(completeEvents) > 0 && completeEvents[0].Data.Text != "Final message without newline" { - t.Errorf("expected complete event to carry buffered text, got %q", completeEvents[0].Data.Text) + if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" { + t.Errorf("expected complete event to have empty text, got %q", completeEvents[0].Data.Text) } - if len(streamingEvents) != 0 { - t.Errorf("expected 0 message_streaming events when no newlines, got %d", len(streamingEvents)) + if len(streamingEvents) != 1 { + t.Errorf("expected 1 message_streaming event when no newlines, got %d", len(streamingEvents)) + } else if streamingEvents[0].Data.Text != "Final message without newline" { + t.Errorf("expected streaming event to carry buffered text, got %q", streamingEvents[0].Data.Text) } } diff --git a/apps/backend/internal/analytics/dto/dto.go b/apps/backend/internal/analytics/dto/dto.go new file mode 100644 index 0000000000..1e447438e2 --- /dev/null +++ b/apps/backend/internal/analytics/dto/dto.go @@ -0,0 +1,96 @@ +package dto + +// TaskStatsDTO represents aggregated statistics for a single task +type TaskStatsDTO struct { + TaskID string `json:"task_id"` + TaskTitle string `json:"task_title"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + State string `json:"state"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + UserMessageCount int `json:"user_message_count"` + ToolCallCount int `json:"tool_call_count"` + TotalDurationMs int64 `json:"total_duration_ms"` + CreatedAt string `json:"created_at"` + CompletedAt *string `json:"completed_at,omitempty"` +} + +// GlobalStatsDTO represents workspace-wide aggregated statistics +type GlobalStatsDTO struct { + TotalTasks int `json:"total_tasks"` + CompletedTasks int `json:"completed_tasks"` + InProgressTasks int `json:"in_progress_tasks"` + TotalSessions int `json:"total_sessions"` + TotalTurns int `json:"total_turns"` + TotalMessages int `json:"total_messages"` + TotalUserMessages int `json:"total_user_messages"` + TotalToolCalls int `json:"total_tool_calls"` + TotalDurationMs int64 `json:"total_duration_ms"` + AvgTurnsPerTask float64 `json:"avg_turns_per_task"` + AvgMessagesPerTask float64 `json:"avg_messages_per_task"` + AvgDurationMsPerTask int64 `json:"avg_duration_ms_per_task"` +} + +// DailyActivityDTO represents activity statistics for a single day +type DailyActivityDTO struct { + Date string `json:"date"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + TaskCount int `json:"task_count"` +} + +// CompletedTaskActivityDTO represents completed task counts for a day +type CompletedTaskActivityDTO struct { + Date string `json:"date"` + CompletedTasks int `json:"completed_tasks"` +} + +// AgentUsageDTO represents usage statistics for a single agent profile +type AgentUsageDTO struct { + AgentProfileID string `json:"agent_profile_id"` + AgentProfileName string `json:"agent_profile_name"` + AgentModel string `json:"agent_model"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + TotalDurationMs int64 `json:"total_duration_ms"` +} + +// RepositoryStatsDTO represents usage statistics for a repository +type RepositoryStatsDTO struct { + RepositoryID string `json:"repository_id"` + RepositoryName string `json:"repository_name"` + TotalTasks int `json:"total_tasks"` + CompletedTasks int `json:"completed_tasks"` + InProgressTasks int `json:"in_progress_tasks"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + UserMessageCount int `json:"user_message_count"` + ToolCallCount int `json:"tool_call_count"` + TotalDurationMs int64 `json:"total_duration_ms"` + TotalCommits int `json:"total_commits"` + TotalFilesChanged int `json:"total_files_changed"` + TotalInsertions int `json:"total_insertions"` + TotalDeletions int `json:"total_deletions"` +} + +// GitStatsDTO represents aggregated git statistics +type GitStatsDTO struct { + TotalCommits int `json:"total_commits"` + TotalFilesChanged int `json:"total_files_changed"` + TotalInsertions int `json:"total_insertions"` + TotalDeletions int `json:"total_deletions"` +} + +// StatsResponse represents the full stats API response +type StatsResponse struct { + Global GlobalStatsDTO `json:"global"` + TaskStats []TaskStatsDTO `json:"task_stats"` + DailyActivity []DailyActivityDTO `json:"daily_activity"` + CompletedActivity []CompletedTaskActivityDTO `json:"completed_activity"` + AgentUsage []AgentUsageDTO `json:"agent_usage"` + RepositoryStats []RepositoryStatsDTO `json:"repository_stats"` + GitStats GitStatsDTO `json:"git_stats"` +} diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go new file mode 100644 index 0000000000..895f09066c --- /dev/null +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/analytics/dto" + "github.com/kandev/kandev/internal/analytics/repository" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +type StatsHandlers struct { + repo repository.Repository + logger *logger.Logger +} + +func NewStatsHandlers(repo repository.Repository, log *logger.Logger) *StatsHandlers { + return &StatsHandlers{ + repo: repo, + logger: log.WithFields(zap.String("component", "analytics-stats-handlers")), + } +} + +func RegisterStatsRoutes(router *gin.Engine, repo repository.Repository, log *logger.Logger) { + handlers := NewStatsHandlers(repo, log) + handlers.registerHTTP(router) +} + +func (h *StatsHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/workspaces/:id/stats", h.httpGetStats) +} + +func (h *StatsHandlers) httpGetStats(c *gin.Context) { + workspaceID := c.Param("id") + if workspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) + return + } + + rangeKey := c.Query("range") + start, days := parseStatsRange(rangeKey) + + globalStats, err := h.repo.GetGlobalStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.logger.Error("failed to get global stats", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + taskStats, err := h.repo.GetTaskStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.logger.Error("failed to get task stats", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + // Get daily activity for the selected range + dailyActivity, err := h.repo.GetDailyActivity(c.Request.Context(), workspaceID, days) + if err != nil { + h.logger.Error("failed to get daily activity", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + // Get completed task activity for the selected range + completedActivity, err := h.repo.GetCompletedTaskActivity(c.Request.Context(), workspaceID, days) + if err != nil { + h.logger.Error("failed to get completed task activity", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + // Convert to DTOs + taskStatsDTOs := make([]dto.TaskStatsDTO, 0, len(taskStats)) + for _, ts := range taskStats { + taskDTO := dto.TaskStatsDTO{ + TaskID: ts.TaskID, + TaskTitle: ts.TaskTitle, + WorkspaceID: ts.WorkspaceID, + BoardID: ts.BoardID, + State: ts.State, + SessionCount: ts.SessionCount, + TurnCount: ts.TurnCount, + MessageCount: ts.MessageCount, + UserMessageCount: ts.UserMessageCount, + ToolCallCount: ts.ToolCallCount, + TotalDurationMs: ts.TotalDurationMs, + CreatedAt: ts.CreatedAt.UTC().Format(time.RFC3339), + } + if ts.CompletedAt != nil { + formatted := ts.CompletedAt.UTC().Format(time.RFC3339) + taskDTO.CompletedAt = &formatted + } + taskStatsDTOs = append(taskStatsDTOs, taskDTO) + } + + dailyActivityDTOs := make([]dto.DailyActivityDTO, 0, len(dailyActivity)) + for _, da := range dailyActivity { + dailyActivityDTOs = append(dailyActivityDTOs, dto.DailyActivityDTO{ + Date: da.Date, + TurnCount: da.TurnCount, + MessageCount: da.MessageCount, + TaskCount: da.TaskCount, + }) + } + + completedActivityDTOs := make([]dto.CompletedTaskActivityDTO, 0, len(completedActivity)) + for _, ca := range completedActivity { + completedActivityDTOs = append(completedActivityDTOs, dto.CompletedTaskActivityDTO{ + Date: ca.Date, + CompletedTasks: ca.CompletedTasks, + }) + } + + // Get agent usage (top 5) + agentUsage, err := h.repo.GetAgentUsage(c.Request.Context(), workspaceID, 5, start) + if err != nil { + h.logger.Error("failed to get agent usage", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + agentUsageDTOs := make([]dto.AgentUsageDTO, 0, len(agentUsage)) + for _, au := range agentUsage { + agentUsageDTOs = append(agentUsageDTOs, dto.AgentUsageDTO{ + AgentProfileID: au.AgentProfileID, + AgentProfileName: au.AgentProfileName, + AgentModel: au.AgentModel, + SessionCount: au.SessionCount, + TurnCount: au.TurnCount, + TotalDurationMs: au.TotalDurationMs, + }) + } + + // Get repository stats + repoStats, err := h.repo.GetRepositoryStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.logger.Error("failed to get repository stats", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + repoStatsDTOs := make([]dto.RepositoryStatsDTO, 0, len(repoStats)) + for _, rs := range repoStats { + repoStatsDTOs = append(repoStatsDTOs, dto.RepositoryStatsDTO{ + RepositoryID: rs.RepositoryID, + RepositoryName: rs.RepositoryName, + TotalTasks: rs.TotalTasks, + CompletedTasks: rs.CompletedTasks, + InProgressTasks: rs.InProgressTasks, + SessionCount: rs.SessionCount, + TurnCount: rs.TurnCount, + MessageCount: rs.MessageCount, + UserMessageCount: rs.UserMessageCount, + ToolCallCount: rs.ToolCallCount, + TotalDurationMs: rs.TotalDurationMs, + TotalCommits: rs.TotalCommits, + TotalFilesChanged: rs.TotalFilesChanged, + TotalInsertions: rs.TotalInsertions, + TotalDeletions: rs.TotalDeletions, + }) + } + + // Get git stats + gitStats, err := h.repo.GetGitStats(c.Request.Context(), workspaceID, start) + if err != nil { + h.logger.Error("failed to get git stats", zap.String("workspace_id", workspaceID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) + return + } + + response := dto.StatsResponse{ + Global: dto.GlobalStatsDTO{ + TotalTasks: globalStats.TotalTasks, + CompletedTasks: globalStats.CompletedTasks, + InProgressTasks: globalStats.InProgressTasks, + TotalSessions: globalStats.TotalSessions, + TotalTurns: globalStats.TotalTurns, + TotalMessages: globalStats.TotalMessages, + TotalUserMessages: globalStats.TotalUserMessages, + TotalToolCalls: globalStats.TotalToolCalls, + TotalDurationMs: globalStats.TotalDurationMs, + AvgTurnsPerTask: globalStats.AvgTurnsPerTask, + AvgMessagesPerTask: globalStats.AvgMessagesPerTask, + AvgDurationMsPerTask: globalStats.AvgDurationMsPerTask, + }, + TaskStats: taskStatsDTOs, + DailyActivity: dailyActivityDTOs, + CompletedActivity: completedActivityDTOs, + AgentUsage: agentUsageDTOs, + RepositoryStats: repoStatsDTOs, + GitStats: dto.GitStatsDTO{ + TotalCommits: gitStats.TotalCommits, + TotalFilesChanged: gitStats.TotalFilesChanged, + TotalInsertions: gitStats.TotalInsertions, + TotalDeletions: gitStats.TotalDeletions, + }, + } + + c.JSON(http.StatusOK, response) +} + +func parseStatsRange(rangeKey string) (*time.Time, int) { + now := time.Now().UTC() + switch rangeKey { + case "week": + start := now.AddDate(0, 0, -7) + return &start, 7 + case "month": + start := now.AddDate(0, 0, -30) + return &start, 30 + default: + start := now.AddDate(0, 0, -30) + return &start, 30 + } +} diff --git a/apps/backend/internal/analytics/models/models.go b/apps/backend/internal/analytics/models/models.go new file mode 100644 index 0000000000..be4d176f76 --- /dev/null +++ b/apps/backend/internal/analytics/models/models.go @@ -0,0 +1,87 @@ +package models + +import "time" + +// TaskStats represents aggregated statistics for a single task +type TaskStats struct { + TaskID string `json:"task_id"` + TaskTitle string `json:"task_title"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + State string `json:"state"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + UserMessageCount int `json:"user_message_count"` + ToolCallCount int `json:"tool_call_count"` + TotalDurationMs int64 `json:"total_duration_ms"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +// GlobalStats represents workspace-wide aggregated statistics +type GlobalStats struct { + TotalTasks int `json:"total_tasks"` + CompletedTasks int `json:"completed_tasks"` + InProgressTasks int `json:"in_progress_tasks"` + TotalSessions int `json:"total_sessions"` + TotalTurns int `json:"total_turns"` + TotalMessages int `json:"total_messages"` + TotalUserMessages int `json:"total_user_messages"` + TotalToolCalls int `json:"total_tool_calls"` + TotalDurationMs int64 `json:"total_duration_ms"` + AvgTurnsPerTask float64 `json:"avg_turns_per_task"` + AvgMessagesPerTask float64 `json:"avg_messages_per_task"` + AvgDurationMsPerTask int64 `json:"avg_duration_ms_per_task"` +} + +// DailyActivity represents activity statistics for a single day +type DailyActivity struct { + Date string `json:"date"` // YYYY-MM-DD format + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + TaskCount int `json:"task_count"` +} + +// CompletedTaskActivity represents completed task counts for a day +type CompletedTaskActivity struct { + Date string `json:"date"` // YYYY-MM-DD format + CompletedTasks int `json:"completed_tasks"` +} + +// AgentUsage represents usage statistics for a single agent profile +type AgentUsage struct { + AgentProfileID string `json:"agent_profile_id"` + AgentProfileName string `json:"agent_profile_name"` + AgentModel string `json:"agent_model"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + TotalDurationMs int64 `json:"total_duration_ms"` +} + +// RepositoryStats represents aggregated statistics for a repository in a workspace +type RepositoryStats struct { + RepositoryID string `json:"repository_id"` + RepositoryName string `json:"repository_name"` + TotalTasks int `json:"total_tasks"` + CompletedTasks int `json:"completed_tasks"` + InProgressTasks int `json:"in_progress_tasks"` + SessionCount int `json:"session_count"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + UserMessageCount int `json:"user_message_count"` + ToolCallCount int `json:"tool_call_count"` + TotalDurationMs int64 `json:"total_duration_ms"` + TotalCommits int `json:"total_commits"` + TotalFilesChanged int `json:"total_files_changed"` + TotalInsertions int `json:"total_insertions"` + TotalDeletions int `json:"total_deletions"` +} + +// GitStats represents aggregated git statistics for a workspace +type GitStats struct { + TotalCommits int `json:"total_commits"` + TotalFilesChanged int `json:"total_files_changed"` + TotalInsertions int `json:"total_insertions"` + TotalDeletions int `json:"total_deletions"` +} diff --git a/apps/backend/internal/analytics/repository/interface.go b/apps/backend/internal/analytics/repository/interface.go new file mode 100644 index 0000000000..aad66970fc --- /dev/null +++ b/apps/backend/internal/analytics/repository/interface.go @@ -0,0 +1,19 @@ +package repository + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/analytics/models" +) + +// Repository defines the interface for analytics/statistics operations. +type Repository interface { + GetTaskStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.TaskStats, error) + GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) + GetDailyActivity(ctx context.Context, workspaceID string, days int) ([]*models.DailyActivity, error) + GetCompletedTaskActivity(ctx context.Context, workspaceID string, days int) ([]*models.CompletedTaskActivity, error) + GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) + GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) + GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) +} diff --git a/apps/backend/internal/analytics/repository/provider.go b/apps/backend/internal/analytics/repository/provider.go new file mode 100644 index 0000000000..7ba7b2f87b --- /dev/null +++ b/apps/backend/internal/analytics/repository/provider.go @@ -0,0 +1,16 @@ +package repository + +import ( + "database/sql" + + "github.com/kandev/kandev/internal/analytics/repository/sqlite" +) + +// Provide creates the SQLite analytics repository using the shared database connection. +func Provide(db *sql.DB) (Repository, func() error, error) { + repo, err := sqlite.NewWithDB(db) + if err != nil { + return nil, nil, err + } + return repo, repo.Close, nil +} diff --git a/apps/backend/internal/analytics/repository/sqlite/repository.go b/apps/backend/internal/analytics/repository/sqlite/repository.go new file mode 100644 index 0000000000..5faba38eb7 --- /dev/null +++ b/apps/backend/internal/analytics/repository/sqlite/repository.go @@ -0,0 +1,19 @@ +// Package sqlite provides SQLite-based analytics repository implementations. +package sqlite + +import "database/sql" + +// Repository provides SQLite-based analytics operations. +type Repository struct { + db *sql.DB +} + +// NewWithDB creates a new analytics repository with an existing database connection. +func NewWithDB(dbConn *sql.DB) (*Repository, error) { + return &Repository{db: dbConn}, nil +} + +// Close is a no-op because this repository does not own the database connection. +func (r *Repository) Close() error { + return nil +} diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go new file mode 100644 index 0000000000..5089014260 --- /dev/null +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -0,0 +1,557 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + "github.com/kandev/kandev/internal/analytics/models" +) + +// parseTimeString parses time strings in various SQLite formats +func parseTimeString(s string) time.Time { + if s == "" { + return time.Time{} + } + // Try various common SQLite datetime formats + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000Z", + "2006-01-02 15:04:05.000", + "2006-01-02T15:04:05", + } + for _, format := range formats { + if t, err := time.Parse(format, s); err == nil { + return t + } + } + return time.Time{} +} + +// GetTaskStats retrieves aggregated statistics for all tasks in a workspace +func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.TaskStats, error) { + var startArg interface{} + if start != nil { + startArg = start.UTC().Format(time.RFC3339) + } + + // Use separate subqueries for counts and duration to avoid row multiplication + query := ` + SELECT + t.id, + t.title, + t.workspace_id, + t.board_id, + t.state, + COALESCE(session_stats.session_count, 0) as session_count, + COALESCE(session_stats.turn_count, 0) as turn_count, + COALESCE(session_stats.message_count, 0) as message_count, + COALESCE(session_stats.user_message_count, 0) as user_message_count, + COALESCE(session_stats.tool_call_count, 0) as tool_call_count, + COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms, + t.created_at, + session_stats.last_completed_at + FROM tasks t + LEFT JOIN ( + SELECT + s.task_id, + COUNT(DISTINCT s.id) as session_count, + COUNT(DISTINCT turn.id) as turn_count, + COUNT(DISTINCT msg.id) as message_count, + COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, + COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count, + MAX(s.completed_at) as last_completed_at + FROM task_sessions s + LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id + LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id + WHERE (? IS NULL OR s.started_at >= ?) + GROUP BY s.task_id + ) session_stats ON session_stats.task_id = t.id + LEFT JOIN ( + SELECT + s.task_id, + SUM(CASE + WHEN turn.completed_at IS NOT NULL + THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 + ELSE 0 + END) as total_duration_ms + FROM task_sessions s + LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id + WHERE (? IS NULL OR s.started_at >= ?) + GROUP BY s.task_id + ) turn_stats ON turn_stats.task_id = t.id + WHERE t.workspace_id = ? AND (? IS NULL OR t.created_at >= ?) + ORDER BY t.updated_at DESC + ` + + rows, err := r.db.QueryContext( + ctx, + query, + startArg, startArg, + startArg, startArg, + workspaceID, startArg, startArg, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*models.TaskStats + for rows.Next() { + var stat models.TaskStats + var completedAtStr sql.NullString // Use NullString to handle legacy string dates + var createdAtStr string // Handle legacy string dates + var totalDurationMs float64 // SQLite returns float from julianday math + err := rows.Scan( + &stat.TaskID, + &stat.TaskTitle, + &stat.WorkspaceID, + &stat.BoardID, + &stat.State, + &stat.SessionCount, + &stat.TurnCount, + &stat.MessageCount, + &stat.UserMessageCount, + &stat.ToolCallCount, + &totalDurationMs, + &createdAtStr, + &completedAtStr, + ) + if err != nil { + return nil, err + } + stat.TotalDurationMs = int64(totalDurationMs) + // Parse created_at from string + stat.CreatedAt = parseTimeString(createdAtStr) + // Parse completed_at from string if valid + if completedAtStr.Valid && completedAtStr.String != "" { + parsedTime := parseTimeString(completedAtStr.String) + if !parsedTime.IsZero() { + stat.CompletedAt = &parsedTime + } + } + results = append(results, &stat) + } + + return results, rows.Err() +} + +// GetGlobalStats retrieves workspace-wide aggregated statistics +func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) { + var startArg interface{} + if start != nil { + startArg = start.UTC().Format(time.RFC3339) + } + + // Use separate subqueries to avoid row multiplication from JOINs + // Count tasks in "done" workflow step (step_type = 'done') as completed + query := ` + SELECT + (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND (? IS NULL OR created_at >= ?)) as total_tasks, + (SELECT COUNT(*) FROM tasks t + JOIN workflow_steps ws ON ws.id = t.workflow_step_id + WHERE t.workspace_id = ? AND ws.step_type = 'done' AND (? IS NULL OR t.created_at >= ?)) as completed_tasks, + (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND state = 'IN_PROGRESS' AND (? IS NULL OR created_at >= ?)) as in_progress_tasks, + (SELECT COUNT(*) FROM task_sessions s JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_sessions, + (SELECT COUNT(*) FROM task_session_turns turn + JOIN task_sessions s ON s.id = turn.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_turns, + (SELECT COUNT(*) FROM task_session_messages msg + JOIN task_sessions s ON s.id = msg.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_messages, + (SELECT COUNT(*) FROM task_session_messages msg + JOIN task_sessions s ON s.id = msg.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND msg.author_type = 'user' AND (? IS NULL OR s.started_at >= ?)) as total_user_messages, + (SELECT COUNT(*) FROM task_session_messages msg + JOIN task_sessions s ON s.id = msg.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND msg.type LIKE 'tool_%' AND (? IS NULL OR s.started_at >= ?)) as total_tool_calls, + (SELECT COALESCE(SUM( + CASE WHEN turn.completed_at IS NOT NULL + THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 + ELSE 0 END + ), 0) FROM task_session_turns turn + JOIN task_sessions s ON s.id = turn.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_duration_ms + ` + + var stats models.GlobalStats + var totalDurationMs float64 // SQLite returns float from julianday math + err := r.db.QueryRowContext(ctx, query, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + workspaceID, startArg, startArg, + ).Scan( + &stats.TotalTasks, + &stats.CompletedTasks, + &stats.InProgressTasks, + &stats.TotalSessions, + &stats.TotalTurns, + &stats.TotalMessages, + &stats.TotalUserMessages, + &stats.TotalToolCalls, + &totalDurationMs, + ) + if err != nil { + return nil, err + } + stats.TotalDurationMs = int64(totalDurationMs) + + // Calculate averages + if stats.TotalTasks > 0 { + stats.AvgTurnsPerTask = float64(stats.TotalTurns) / float64(stats.TotalTasks) + stats.AvgMessagesPerTask = float64(stats.TotalMessages) / float64(stats.TotalTasks) + stats.AvgDurationMsPerTask = stats.TotalDurationMs / int64(stats.TotalTasks) + } + + return &stats, nil +} + +// GetDailyActivity retrieves daily activity statistics for the last N days +func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, days int) ([]*models.DailyActivity, error) { + query := ` + WITH RECURSIVE dates(date) AS ( + SELECT date('now', '-' || ? || ' days') + UNION ALL + SELECT date(date, '+1 day') + FROM dates + WHERE date < date('now') + ) + SELECT + d.date, + COALESCE(activity.turn_count, 0) as turn_count, + COALESCE(activity.message_count, 0) as message_count, + COALESCE(activity.task_count, 0) as task_count + FROM dates d + LEFT JOIN ( + SELECT + date(turn.started_at) as activity_date, + COUNT(DISTINCT turn.id) as turn_count, + COUNT(DISTINCT msg.id) as message_count, + COUNT(DISTINCT t.id) as task_count + FROM task_session_turns turn + JOIN task_sessions s ON s.id = turn.task_session_id + JOIN tasks t ON t.id = s.task_id + LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id + AND date(msg.created_at) = date(turn.started_at) + WHERE t.workspace_id = ? + GROUP BY date(turn.started_at) + ) activity ON activity.activity_date = d.date + ORDER BY d.date ASC + ` + + rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*models.DailyActivity + for rows.Next() { + var activity models.DailyActivity + err := rows.Scan( + &activity.Date, + &activity.TurnCount, + &activity.MessageCount, + &activity.TaskCount, + ) + if err != nil { + return nil, err + } + results = append(results, &activity) + } + + return results, rows.Err() +} + +// GetCompletedTaskActivity retrieves completed task counts for the last N days +func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID string, days int) ([]*models.CompletedTaskActivity, error) { + query := ` + WITH RECURSIVE dates(date) AS ( + SELECT date('now', '-' || ? || ' days') + UNION ALL + SELECT date(date, '+1 day') + FROM dates + WHERE date < date('now') + ) + SELECT + d.date, + COALESCE(activity.completed_tasks, 0) as completed_tasks + FROM dates d + LEFT JOIN ( + SELECT + date(ts.completed_at) as activity_date, + COUNT(DISTINCT t.id) as completed_tasks + FROM tasks t + JOIN workflow_steps ws ON ws.id = t.workflow_step_id + JOIN ( + SELECT task_id, MAX(completed_at) as completed_at + FROM task_sessions + WHERE completed_at IS NOT NULL + GROUP BY task_id + ) ts ON ts.task_id = t.id + WHERE t.workspace_id = ? AND ws.step_type = 'done' + GROUP BY date(ts.completed_at) + ) activity ON activity.activity_date = d.date + ORDER BY d.date ASC + ` + + rows, err := r.db.QueryContext(ctx, query, days-1, workspaceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*models.CompletedTaskActivity + for rows.Next() { + var activity models.CompletedTaskActivity + err := rows.Scan( + &activity.Date, + &activity.CompletedTasks, + ) + if err != nil { + return nil, err + } + results = append(results, &activity) + } + + return results, rows.Err() +} + +// GetRepositoryStats retrieves aggregated statistics for repositories in a workspace +func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) { + var startArg interface{} + if start != nil { + startArg = start.UTC().Format(time.RFC3339) + } + + query := ` + SELECT + r.id, + r.name, + COALESCE(task_stats.total_tasks, 0) as total_tasks, + COALESCE(task_stats.completed_tasks, 0) as completed_tasks, + COALESCE(task_stats.in_progress_tasks, 0) as in_progress_tasks, + COALESCE(session_stats.session_count, 0) as session_count, + COALESCE(session_stats.turn_count, 0) as turn_count, + COALESCE(session_stats.message_count, 0) as message_count, + COALESCE(session_stats.user_message_count, 0) as user_message_count, + COALESCE(session_stats.tool_call_count, 0) as tool_call_count, + COALESCE(duration_stats.total_duration_ms, 0) as total_duration_ms, + COALESCE(git_stats.total_commits, 0) as total_commits, + COALESCE(git_stats.total_files_changed, 0) as total_files_changed, + COALESCE(git_stats.total_insertions, 0) as total_insertions, + COALESCE(git_stats.total_deletions, 0) as total_deletions + FROM repositories r + LEFT JOIN ( + SELECT + tr.repository_id, + COUNT(DISTINCT t.id) as total_tasks, + COUNT(DISTINCT CASE WHEN ws.step_type = 'done' THEN t.id END) as completed_tasks, + COUNT(DISTINCT CASE WHEN t.state = 'IN_PROGRESS' THEN t.id END) as in_progress_tasks + FROM task_repositories tr + JOIN tasks t ON t.id = tr.task_id + LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id + WHERE (? IS NULL OR t.created_at >= ?) + GROUP BY tr.repository_id + ) task_stats ON task_stats.repository_id = r.id + LEFT JOIN ( + SELECT + tr.repository_id, + COUNT(DISTINCT s.id) as session_count, + COUNT(DISTINCT turn.id) as turn_count, + COUNT(DISTINCT msg.id) as message_count, + COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, + COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%' THEN msg.id END) as tool_call_count + FROM task_repositories tr + JOIN task_sessions s ON s.task_id = tr.task_id + LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id + LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id + WHERE (? IS NULL OR s.started_at >= ?) + GROUP BY tr.repository_id + ) session_stats ON session_stats.repository_id = r.id + LEFT JOIN ( + SELECT + tr.repository_id, + COALESCE(SUM(CASE + WHEN turn.completed_at IS NOT NULL + THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 + ELSE 0 + END), 0) as total_duration_ms + FROM task_repositories tr + JOIN task_sessions s ON s.task_id = tr.task_id + LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id + WHERE (? IS NULL OR s.started_at >= ?) + GROUP BY tr.repository_id + ) duration_stats ON duration_stats.repository_id = r.id + LEFT JOIN ( + SELECT + s.repository_id, + COUNT(DISTINCT c.id) as total_commits, + COALESCE(SUM(c.files_changed), 0) as total_files_changed, + COALESCE(SUM(c.insertions), 0) as total_insertions, + COALESCE(SUM(c.deletions), 0) as total_deletions + FROM task_session_commits c + JOIN task_sessions s ON s.id = c.session_id + WHERE s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) + GROUP BY s.repository_id + ) git_stats ON git_stats.repository_id = r.id + WHERE r.workspace_id = ? + ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC + ` + + rows, err := r.db.QueryContext( + ctx, + query, + startArg, startArg, + startArg, startArg, + startArg, startArg, + startArg, startArg, + workspaceID, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*models.RepositoryStats + for rows.Next() { + var stats models.RepositoryStats + var totalDurationMs float64 + err := rows.Scan( + &stats.RepositoryID, + &stats.RepositoryName, + &stats.TotalTasks, + &stats.CompletedTasks, + &stats.InProgressTasks, + &stats.SessionCount, + &stats.TurnCount, + &stats.MessageCount, + &stats.UserMessageCount, + &stats.ToolCallCount, + &totalDurationMs, + &stats.TotalCommits, + &stats.TotalFilesChanged, + &stats.TotalInsertions, + &stats.TotalDeletions, + ) + if err != nil { + return nil, err + } + stats.TotalDurationMs = int64(totalDurationMs) + results = append(results, &stats) + } + + return results, rows.Err() +} + +// GetAgentUsage retrieves usage statistics per agent profile +func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) { + var startArg interface{} + if start != nil { + startArg = start.UTC().Format(time.RFC3339) + } + + query := ` + SELECT + s.agent_profile_id, + COALESCE( + json_extract(s.agent_profile_snapshot, '$.name'), + json_extract(s.agent_profile_snapshot, '$.agent_display_name'), + s.agent_profile_id + ) as agent_profile_name, + COALESCE( + json_extract(s.agent_profile_snapshot, '$.model'), + json_extract(s.agent_profile_snapshot, '$.model_name'), + json_extract(s.agent_profile_snapshot, '$.llm'), + '' + ) as agent_model, + COUNT(DISTINCT s.id) as session_count, + COUNT(DISTINCT turn.id) as turn_count, + COALESCE(SUM(CASE + WHEN turn.completed_at IS NOT NULL + THEN (julianday(turn.completed_at) - julianday(turn.started_at)) * 86400000 + ELSE 0 + END), 0) as total_duration_ms + FROM task_sessions s + JOIN tasks t ON t.id = s.task_id + LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id + WHERE t.workspace_id = ? AND s.agent_profile_id != '' AND (? IS NULL OR s.started_at >= ?) + GROUP BY s.agent_profile_id + ORDER BY session_count DESC + LIMIT ? + ` + + rows, err := r.db.QueryContext(ctx, query, workspaceID, startArg, startArg, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*models.AgentUsage + for rows.Next() { + var usage models.AgentUsage + var totalDurationMs float64 + err := rows.Scan( + &usage.AgentProfileID, + &usage.AgentProfileName, + &usage.AgentModel, + &usage.SessionCount, + &usage.TurnCount, + &totalDurationMs, + ) + if err != nil { + return nil, err + } + usage.TotalDurationMs = int64(totalDurationMs) + results = append(results, &usage) + } + + return results, rows.Err() +} + +// GetGitStats retrieves aggregated git statistics for a workspace +func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) { + var startArg interface{} + if start != nil { + startArg = start.UTC().Format(time.RFC3339) + } + + query := ` + SELECT + COUNT(DISTINCT c.id) as total_commits, + COALESCE(SUM(c.files_changed), 0) as total_files_changed, + COALESCE(SUM(c.insertions), 0) as total_insertions, + COALESCE(SUM(c.deletions), 0) as total_deletions + FROM task_session_commits c + JOIN task_sessions s ON s.id = c.session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND (? IS NULL OR c.committed_at >= ?) + ` + + var stats models.GitStats + err := r.db.QueryRowContext(ctx, query, workspaceID, startArg, startArg).Scan( + &stats.TotalCommits, + &stats.TotalFilesChanged, + &stats.TotalInsertions, + &stats.TotalDeletions, + ) + if err != nil { + return nil, err + } + + return &stats, nil +} diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 6d07fcc18b..6e77f07159 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -12,8 +12,8 @@ import ( // mockAgentManager implements AgentManagerClient for testing type mockAgentManager struct { - launchAgentFunc func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) - startAgentProcessFunc func(ctx context.Context, agentExecutionID string) error + launchAgentFunc func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) + startAgentProcessFunc func(ctx context.Context, agentExecutionID string) error resolveAgentProfileFunc func(ctx context.Context, profileID string) (*AgentProfileInfo, error) } @@ -76,9 +76,9 @@ type mockRepository struct { executors map[string]*models.Executor // Track calls for verification - createTaskSessionCalls []*models.TaskSession - updateTaskSessionCalls []*models.TaskSession - setSessionPrimaryCalls []string + createTaskSessionCalls []*models.TaskSession + updateTaskSessionCalls []*models.TaskSession + setSessionPrimaryCalls []string } func newMockRepository() *mockRepository { @@ -167,129 +167,265 @@ func (m *mockRepository) GetWorkspace(ctx context.Context, id string) (*models.W // Stub implementations for other repository methods (matching repository.Repository interface) // Workspace operations -func (m *mockRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { return nil } -func (m *mockRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { return nil } +func (m *mockRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { + return nil +} +func (m *mockRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { + return nil +} func (m *mockRepository) DeleteWorkspace(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { return nil, nil } +func (m *mockRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { + return nil, nil +} // Task operations func (m *mockRepository) CreateTask(ctx context.Context, task *models.Task) error { return nil } -func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { return nil, nil } +func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { + return nil, nil +} func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error { return nil } -func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { return nil, nil } -func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { return nil, 0, nil } -func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { return nil, nil } -func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error { return nil } -func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { return nil } +func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + return nil, nil +} +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { + return nil, 0, nil +} +func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { + return nil, nil +} +func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error { + return nil +} +func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { + return nil +} // TaskRepository operations -func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { return nil } -func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { return nil, nil } -func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { return nil, nil } -func (m *mockRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { return nil } +func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + return nil +} +func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { + return nil, nil +} +func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + return nil +} func (m *mockRepository) DeleteTaskRepository(ctx context.Context, id string) error { return nil } -func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { return nil } +func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { + return nil +} // Board operations func (m *mockRepository) CreateBoard(ctx context.Context, board *models.Board) error { return nil } -func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { return nil, nil } +func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { + return nil, nil +} func (m *mockRepository) UpdateBoard(ctx context.Context, board *models.Board) error { return nil } -func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { return nil, nil } +func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { + return nil, nil +} // Message operations -func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { return nil } -func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { return nil, nil } -func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { return nil, nil } -func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) { return nil, nil } -func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } -func (m *mockRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { return nil, nil } -func (m *mockRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) { return nil, false, nil } +func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { + return nil +} +func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { + return nil +} +func (m *mockRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) { + return nil, false, nil +} func (m *mockRepository) DeleteMessage(ctx context.Context, id string) error { return nil } // Turn operations func (m *mockRepository) CreateTurn(ctx context.Context, turn *models.Turn) error { return nil } -func (m *mockRepository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { return nil, nil } -func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { return nil, nil } +func (m *mockRepository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { + return nil, nil +} +func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { + return nil, nil +} func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) error { return nil } -func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { return nil, nil } +func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { + return nil, nil +} // Task Session operations -func (m *mockRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { return nil, nil } +func (m *mockRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + return nil, nil +} func (m *mockRepository) ClearSessionExecutionID(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { return false, nil } -func (m *mockRepository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { return false, nil } -func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { return false, nil } -func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { return false, nil } +func (m *mockRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { + return false, nil +} func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error { return nil } // Workflow-related session operations -func (m *mockRepository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) { return nil, nil } -func (m *mockRepository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]int, error) { return nil, nil } -func (m *mockRepository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { return nil, nil } -func (m *mockRepository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { return nil } -func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { return nil } +func (m *mockRepository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) { + return nil, nil +} +func (m *mockRepository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]int, error) { + return nil, nil +} +func (m *mockRepository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { + return nil +} +func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { + return nil +} // Task Session Worktree operations -func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { return nil, nil } +func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { + return nil, nil +} func (m *mockRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { return nil } -func (m *mockRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { return nil } +func (m *mockRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { + return nil +} // Git Snapshot operations -func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { return nil } -func (m *mockRepository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { return nil, nil } -func (m *mockRepository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { return nil, nil } -func (m *mockRepository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { return nil, nil } +func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { + return nil +} +func (m *mockRepository) GetLatestGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return nil, nil +} +func (m *mockRepository) GetFirstGitSnapshot(ctx context.Context, sessionID string) (*models.GitSnapshot, error) { + return nil, nil +} +func (m *mockRepository) GetGitSnapshotsBySession(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { + return nil, nil +} // Session Commit operations -func (m *mockRepository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error { return nil } -func (m *mockRepository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { return nil, nil } -func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { return nil, nil } +func (m *mockRepository) CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error { + return nil +} +func (m *mockRepository) GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) { + return nil, nil +} +func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID string) (*models.SessionCommit, error) { + return nil, nil +} func (m *mockRepository) DeleteSessionCommit(ctx context.Context, id string) error { return nil } // Repository operations -func (m *mockRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { return nil } -func (m *mockRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { return nil } +func (m *mockRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { + return nil +} +func (m *mockRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { + return nil +} func (m *mockRepository) DeleteRepository(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { return nil, nil } +func (m *mockRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + return nil, nil +} // Repository script operations -func (m *mockRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { return nil } -func (m *mockRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { return nil, nil } -func (m *mockRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { return nil } +func (m *mockRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + return nil +} +func (m *mockRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + return nil, nil +} +func (m *mockRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + return nil +} func (m *mockRepository) DeleteRepositoryScript(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { return nil, nil } +func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + return nil, nil +} // Executor operations -func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { return nil } -func (m *mockRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { return nil } +func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { + return nil +} +func (m *mockRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { + return nil +} func (m *mockRepository) DeleteExecutor(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { return nil, nil } +func (m *mockRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + return nil, nil +} // Executor running operations -func (m *mockRepository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { return nil, nil } -func (m *mockRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { return nil, nil } -func (m *mockRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error { return nil } +func (m *mockRepository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { + return nil, nil +} +func (m *mockRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { + return nil, nil +} +func (m *mockRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error { + return nil +} // Environment operations -func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { return nil } -func (m *mockRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { return nil, nil } -func (m *mockRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { return nil } +func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { + return nil +} +func (m *mockRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + return nil, nil +} +func (m *mockRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { + return nil +} func (m *mockRepository) DeleteEnvironment(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { return nil, nil } +func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + return nil, nil +} // Task Plan operations func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } -func (m *mockRepository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { return nil, nil } +func (m *mockRepository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { + return nil, nil +} func (m *mockRepository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } -func (m *mockRepository) DeleteTaskPlan(ctx context.Context, taskID string) error { return nil } +func (m *mockRepository) DeleteTaskPlan(ctx context.Context, taskID string) error { return nil } // Close operation func (m *mockRepository) Close() error { return nil } diff --git a/apps/web/app/stats/page.tsx b/apps/web/app/stats/page.tsx new file mode 100644 index 0000000000..723c32ceb3 --- /dev/null +++ b/apps/web/app/stats/page.tsx @@ -0,0 +1,49 @@ +import { fetchStats, type StatsRange } from '@/lib/api/domains/stats-api'; +import { fetchUserSettings } from '@/lib/api/domains/settings-api'; +import { listWorkspaces } from '@/lib/api'; +import { StatsPageClient } from './stats-page-client'; + +type StatsPageProps = { + searchParams?: Promise<{ + range?: StatsRange; + }>; +}; + +export default async function StatsPage({ searchParams }: StatsPageProps) { + let stats = null; + let error = null; + let workspaceId: string | undefined; + const params = searchParams ? await searchParams : undefined; + const range = params?.range; + + try { + // Get user settings to find active workspace + const [userSettingsResponse, workspacesResponse] = await Promise.all([ + fetchUserSettings({ cache: 'no-store' }), + listWorkspaces({ cache: 'no-store' }), + ]); + + const settingsWorkspaceId = userSettingsResponse?.settings?.workspace_id; + const workspaces = workspacesResponse?.workspaces ?? []; + + // Find active workspace: user setting > first workspace + workspaceId = + workspaces.find((w) => w.id === settingsWorkspaceId)?.id ?? + workspaces[0]?.id; + + if (workspaceId) { + stats = await fetchStats(workspaceId, { cache: 'no-store' }, range); + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch stats'; + } + + return ( + + ); +} diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx new file mode 100644 index 0000000000..7430ef30f4 --- /dev/null +++ b/apps/web/app/stats/stats-page-client.tsx @@ -0,0 +1,1167 @@ +'use client'; + +import Link from 'next/link'; +import { IconArrowLeft, IconGitCommit } from '@tabler/icons-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; +import { Button } from '@kandev/ui/button'; +import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip'; +import type { + StatsResponse, + DailyActivityDTO, + AgentUsageDTO, + RepositoryStatsDTO, + CompletedTaskActivityDTO, +} from '@/lib/types/http'; +import { useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +interface StatsPageClientProps { + stats: StatsResponse | null; + error: string | null; + workspaceId?: string; + activeRange?: RangeKey; +} + +const EMPTY_STATS: StatsResponse = { + global: { + total_tasks: 0, + completed_tasks: 0, + in_progress_tasks: 0, + total_sessions: 0, + total_turns: 0, + total_messages: 0, + total_user_messages: 0, + total_tool_calls: 0, + total_duration_ms: 0, + avg_turns_per_task: 0, + avg_messages_per_task: 0, + avg_duration_ms_per_task: 0, + }, + task_stats: [], + daily_activity: [], + completed_activity: [], + agent_usage: [], + repository_stats: [], + git_stats: { + total_commits: 0, + total_files_changed: 0, + total_insertions: 0, + total_deletions: 0, + }, +}; + +function formatDuration(ms: number): string { + if (ms === 0) return '—'; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +function formatPercent(value: number): string { + return `${Math.round(value)}%`; +} + +type RangeKey = 'week' | 'month'; + +function getRangeLabel(range: RangeKey): string { + switch (range) { + case 'week': + return 'Last Week'; + case 'month': + return 'Last Month'; + default: + return 'Last Month'; + } +} + +function formatMonthLabel(date: Date): string { + return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); +} + +function formatWeekLabel(date: Date): string { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function getHeatmapColor(intensity: number): string { + if (intensity === 0) return 'bg-muted'; + if (intensity < 0.25) return 'bg-emerald-500/30'; + if (intensity < 0.5) return 'bg-emerald-500/50'; + if (intensity < 0.75) return 'bg-emerald-500/70'; + return 'bg-emerald-500/90'; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +interface HeatmapProps { + dailyActivity: DailyActivityDTO[]; +} + +function ActivityHeatmap({ dailyActivity }: HeatmapProps) { + const { weeks, maxActivity, monthLabels } = useMemo(() => { + if (!dailyActivity || dailyActivity.length === 0) { + return { weeks: [] as DailyActivityDTO[][], maxActivity: 1, monthLabels: [] as { index: number; label: string }[] }; + } + + const max = Math.max(...dailyActivity.map(d => d.turn_count + d.message_count), 1); + const startDate = new Date(`${dailyActivity[0].date}T00:00:00`); + const startDay = startDate.getDay(); + const padded: DailyActivityDTO[] = []; + + for (let i = 0; i < startDay; i++) { + padded.push({ date: '', turn_count: 0, message_count: 0, task_count: 0 }); + } + padded.push(...dailyActivity); + + const weeksMap: DailyActivityDTO[][] = []; + for (let i = 0; i < padded.length; i += 7) { + weeksMap.push(padded.slice(i, i + 7)); + } + + const monthMarkers: { index: number; label: string }[] = []; + let lastMonth = -1; + weeksMap.forEach((week, index) => { + const firstDay = week.find((d) => d.date)?.date; + if (!firstDay) return; + const date = new Date(`${firstDay}T00:00:00`); + const month = date.getMonth(); + if (month !== lastMonth) { + monthMarkers.push({ index, label: date.toLocaleDateString('en-US', { month: 'short' }) }); + lastMonth = month; + } + }); + + return { weeks: weeksMap, maxActivity: max, monthLabels: monthMarkers }; + }, [dailyActivity]); + + const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', '']; + + return ( +
+
+
+ {monthLabels.map((label) => ( +
+ {label.label} +
+ ))} +
+ +
+
+ {dayLabels.map((label, i) => ( +
+ {label} +
+ ))} +
+ + +
+ {weeks.map((week, weekIndex) => ( +
+ {Array.from({ length: 7 }).map((_, dayIndex) => { + const day = week[dayIndex] ?? { date: '', turn_count: 0, message_count: 0, task_count: 0 }; + const activity = day.turn_count + day.message_count; + const intensity = activity / maxActivity; + + if (!day.date) { + return
; + } + + return ( + + +
+ + +
{formatDate(day.date)}
+
+ {day.turn_count} turns, {day.message_count} messages +
+
+ + ); + })} +
+ ))} +
+ +
+
+ +
+ Less +
+
+
+
+
+
+
+ More +
+
+ ); +} + +function AgentUsageList({ agentUsage }: { agentUsage: AgentUsageDTO[] }) { + if (!agentUsage || agentUsage.length === 0) { + return
No agent usage data yet.
; + } + + const maxSessions = Math.max(...agentUsage.map((a) => a.session_count)); + + return ( +
+ {agentUsage.map((agent) => ( +
+
+
+
{agent.agent_profile_name}
+ {agent.agent_model && ( +
+ {agent.agent_model} +
+ )} +
+ + {agent.session_count} + +
+
+
+
+
+ ))} +
+ ); +} + +function RepositoryStatsGrid({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) { + if (!repositoryStats || repositoryStats.length === 0) { + return
No repository stats yet.
; + } + + return ( +
+ {repositoryStats.map((repo) => { + const completionRate = repo.total_tasks > 0 + ? (repo.completed_tasks / repo.total_tasks) * 100 + : 0; + const hasGit = repo.total_commits > 0 || repo.total_files_changed > 0; + + return ( +
+
+
+ {repo.repository_name} +
+
+ {formatDuration(repo.total_duration_ms)} +
+
+ +
+ {repo.total_tasks} tasks + {repo.session_count} sessions + {repo.turn_count} turns + {repo.message_count} msgs +
+ +
+
+ Completion + + {formatPercent(completionRate)} · {repo.completed_tasks}/{repo.total_tasks} + +
+
+ +
+ {hasGit ? ( +
+ {repo.total_commits} commits + + + +{repo.total_insertions.toLocaleString()} + {' '} + + −{repo.total_deletions.toLocaleString()} + + +
+ ) : ( +
No git activity yet.
+ )} +
+
+ ); + })} +
+ ); +} + +function TopRepositories({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) { + if (!repositoryStats || repositoryStats.length === 0) { + return
No repository stats yet.
; + } + + const topByTurns = [...repositoryStats] + .filter((repo) => repo.turn_count > 0) + .sort((a, b) => b.turn_count - a.turn_count) + .slice(0, 3); + + const topByMessages = [...repositoryStats] + .filter((repo) => repo.message_count > 0) + .sort((a, b) => b.message_count - a.message_count) + .slice(0, 3); + + return ( +
+
+
+ Top By Turns +
+
+ {topByTurns.length === 0 && ( +
No turn activity yet.
+ )} + {topByTurns.map((repo, idx) => ( +
+ {idx + 1}. +
+
+ {repo.repository_name} +
+
+
+ {repo.turn_count} +
+
+ ))} +
+
+
+
+ Top By Messages +
+
+ {topByMessages.length === 0 && ( +
No message activity yet.
+ )} + {topByMessages.map((repo, idx) => ( +
+ {idx + 1}. +
+
+ {repo.repository_name} +
+
+
+ {repo.message_count} +
+
+ ))} +
+
+
+ ); +} + +function RepoLeaders({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) { + if (!repositoryStats || repositoryStats.length === 0) { + return
No repository stats yet.
; + } + + const topByTasks = [...repositoryStats] + .filter((repo) => repo.total_tasks > 0) + .sort((a, b) => b.total_tasks - a.total_tasks) + .slice(0, 3); + + const topByTime = [...repositoryStats] + .filter((repo) => repo.total_duration_ms > 0) + .sort((a, b) => b.total_duration_ms - a.total_duration_ms) + .slice(0, 3); + + const topByCommits = [...repositoryStats] + .filter((repo) => repo.total_commits > 0) + .sort((a, b) => b.total_commits - a.total_commits) + .slice(0, 3); + + return ( +
+
+
+ Most Tasks +
+
+ {topByTasks.length === 0 && ( +
No tasks yet.
+ )} + {topByTasks.map((repo, idx) => ( +
+ {idx + 1}. +
+
+ {repo.repository_name} +
+
+
+ {repo.total_tasks} +
+
+ ))} +
+
+ +
+
+ Most Time +
+
+ {topByTime.length === 0 && ( +
No time logged yet.
+ )} + {topByTime.map((repo, idx) => ( +
+ {idx + 1}. +
+
+ {repo.repository_name} +
+
+
+ {formatDuration(repo.total_duration_ms)} +
+
+ ))} +
+
+ +
+
+ Most Commits +
+
+ {topByCommits.length === 0 && ( +
No commits yet.
+ )} + {topByCommits.map((repo, idx) => ( +
+ {idx + 1}. +
+
+ {repo.repository_name} +
+
+
+ {repo.total_commits} +
+
+ ))} +
+
+
+ ); +} + +type CompletionBucket = 'day' | 'week' | 'month'; + +function CompletedTasksChart({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) { + const [bucket, setBucket] = useState('day'); + const safeCompleted = completedActivity ?? []; + + const series = useMemo(() => { + if (safeCompleted.length === 0) { + return [] as { label: string; count: number; date: Date }[]; + } + + const toDate = (dateStr: string) => { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day)); + }; + + const data = safeCompleted + .map((item) => ({ date: toDate(item.date), count: item.completed_tasks })) + .filter((item) => Number.isFinite(item.date.getTime())); + + const buckets = new Map(); + + data.forEach((item) => { + if (bucket === 'day') { + const key = item.date.toISOString().slice(0, 10); + buckets.set(key, { label: formatDate(key), count: item.count, date: item.date }); + return; + } + + if (bucket === 'week') { + const d = new Date(item.date); + const day = d.getUTCDay(); + const diff = (day + 6) % 7; + d.setUTCDate(d.getUTCDate() - diff); + d.setUTCHours(0, 0, 0, 0); + const key = d.toISOString().slice(0, 10); + const existing = buckets.get(key); + const nextCount = (existing?.count ?? 0) + item.count; + buckets.set(key, { label: formatWeekLabel(d), count: nextCount, date: d }); + return; + } + + const d = new Date(Date.UTC(item.date.getUTCFullYear(), item.date.getUTCMonth(), 1)); + const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; + const existing = buckets.get(key); + const nextCount = (existing?.count ?? 0) + item.count; + buckets.set(key, { label: formatMonthLabel(d), count: nextCount, date: d }); + }); + + return Array.from(buckets.values()).sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [bucket, safeCompleted]); + + const maxCount = Math.max(...series.map((item) => item.count), 1); + + if (safeCompleted.length === 0) { + return
No completed task data yet.
; + } + + return ( +
+
+ Bucket + + + +
+ +
+ + {series.map((item, index) => { + const height = Math.max(6, Math.round((item.count / maxCount) * 100)); + return ( + + +
+ + +
{item.label}
+
{item.count} completed
+
+ + ); + })} + +
+ +
+ {series[0]?.label ?? ''} + {series[series.length - 1]?.label ?? ''} +
+
+ ); +} + +function MostProductiveSummary({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) { + const safeCompleted = completedActivity ?? []; + + const stats = useMemo(() => { + if (safeCompleted.length === 0) { + return { + maxWeekday: { idx: 0, value: 0 }, + maxMonth: { idx: 0, value: 0 }, + maxYear: { year: 0, value: 0 }, + }; + } + + const weekdayTotals = Array(7).fill(0); + const monthTotals = Array(12).fill(0); + const yearTotals = new Map(); + + safeCompleted.forEach((item) => { + const [year, month, day] = item.date.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + weekdayTotals[date.getUTCDay()] += item.completed_tasks; + monthTotals[date.getUTCMonth()] += item.completed_tasks; + yearTotals.set(year, (yearTotals.get(year) ?? 0) + item.completed_tasks); + }); + + const maxWeekday = weekdayTotals.reduce((best, value, idx) => ( + value > best.value ? { idx, value } : best + ), { idx: 0, value: weekdayTotals[0] ?? 0 }); + + const maxMonth = monthTotals.reduce((best, value, idx) => ( + value > best.value ? { idx, value } : best + ), { idx: 0, value: monthTotals[0] ?? 0 }); + + let maxYear = { year: 0, value: 0 }; + yearTotals.forEach((value, year) => { + if (value > maxYear.value) { + maxYear = { year, value }; + } + }); + + return { maxWeekday, maxMonth, maxYear }; + }, [safeCompleted]); + + if (safeCompleted.length === 0) { + return
No completed task data yet.
; + } + + const weekdayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + return ( +
+
+ Best weekday + + {weekdayNames[stats.maxWeekday.idx]} · {stats.maxWeekday.value} + +
+
+ Best month + + {monthNames[stats.maxMonth.idx]} · {stats.maxMonth.value} + +
+
+ Best year + + {stats.maxYear.year || '—'} · {stats.maxYear.value} + +
+
+ ); +} + +export function StatsPageClient({ stats, error, workspaceId, activeRange }: StatsPageClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [copied, setCopied] = useState(false); + const range = (activeRange ?? 'month') as RangeKey; + const rangeLabel = getRangeLabel(range); + const resolvedStats = stats ?? EMPTY_STATS; + + const completedInRange = useMemo(() => ( + (resolvedStats.completed_activity ?? []).reduce((sum, item) => sum + item.completed_tasks, 0) + ), [resolvedStats.completed_activity]); + + const statsSummary = useMemo(() => { + const repoCount = resolvedStats.repository_stats.length; + const completed = resolvedStats.global.completed_tasks; + const inProgress = resolvedStats.global.in_progress_tasks; + const total = resolvedStats.global.total_tasks; + const completion = total > 0 ? `${Math.round((completed / total) * 100)}%` : '—'; + const time = formatDuration(resolvedStats.global.total_duration_ms); + const avgTask = formatDuration(resolvedStats.global.avg_duration_ms_per_task); + const topRepo = resolvedStats.repository_stats + .filter((repo) => repo.total_tasks > 0) + .sort((a, b) => b.total_tasks - a.total_tasks)[0]; + const topRepoLabel = topRepo ? `${topRepo.repository_name} (${topRepo.total_tasks} tasks)` : '—'; + const hasGitStats = resolvedStats.git_stats + && (resolvedStats.git_stats.total_commits > 0 || resolvedStats.git_stats.total_files_changed > 0); + const gitLine = hasGitStats + ? `${resolvedStats.git_stats.total_commits} commits, +${resolvedStats.git_stats.total_insertions.toLocaleString()}/-${resolvedStats.git_stats.total_deletions.toLocaleString()}` + : 'no git activity'; + + return [ + `*KanDev Stats — ${rangeLabel}*`, + `- Tasks: ${total} total (${completed} done, ${inProgress} in progress) · ${completion} completion`, + `- Completed (${rangeLabel}): ${completedInRange}`, + `- Time: ${time} total · ${avgTask} avg/task`, + `- Repos: ${repoCount} tracked · Top repo: ${topRepoLabel}`, + `- Git: ${gitLine}`, + ].join('\n'); + }, [completedInRange, rangeLabel, resolvedStats]); + + // No workspace selected + if (!workspaceId) { + return ( +
+
+ KanDev + / + Statistics +
+
+

Select a workspace to view statistics.

+
+
+ ); + } + + if (error) { + return ( +
+
+ KanDev + / + Statistics +
+
+

Error loading stats: {error}

+
+
+ ); + } + + if (!stats) { + return ( +
+
+ KanDev + / + Statistics +
+
+

No stats available.

+
+
+ ); + } + + const { + global, + task_stats, + daily_activity, + completed_activity, + agent_usage, + repository_stats, + git_stats, + } = resolvedStats; + const completionRate = global.total_tasks > 0 + ? Math.round((global.completed_tasks / global.total_tasks) * 100) + : 0; + const hasGitStats = git_stats && (git_stats.total_commits > 0 || git_stats.total_files_changed > 0); + const toolShare = global.total_messages > 0 + ? Math.round((global.total_tool_calls / global.total_messages) * 100) + : 0; + const userShare = global.total_messages > 0 + ? Math.round((global.total_user_messages / global.total_messages) * 100) + : 0; + const avgTurnsPerSession = global.total_sessions > 0 + ? global.total_turns / global.total_sessions + : 0; + const avgMessagesPerSession = global.total_sessions > 0 + ? global.total_messages / global.total_sessions + : 0; + const handleCopyStats = async () => { + try { + await navigator.clipboard.writeText(statsSummary); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy stats summary', err); + } + }; + + const handleRangeChange = (nextRange: RangeKey) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + params.set('range', nextRange); + router.push(`/stats?${params.toString()}`); + }; + + return ( +
+ {/* Header matching task session top bar */} +
+ +
+ KanDev + › + Statistics + · + + {global.total_tasks} tasks · {global.total_sessions} sessions · {formatDuration(global.total_duration_ms)} + +
+
+ { + if (value) handleRangeChange(value as RangeKey); + }} + variant="outline" + className="h-7" + > + {(['week', 'month'] as RangeKey[]).map((key) => ( + + {getRangeLabel(key)} + + ))} + + +
+
+ + {/* Scrollable content area */} +
+
+
+ {/* Overview Cards */} +
+ {/* Tasks Overview */} + + + Tasks + + +
{global.total_tasks}
+
+ {global.completed_tasks} completed + {global.in_progress_tasks} in progress +
+ {global.total_tasks > 0 && ( +
+
+ Completion rate + {completionRate}% +
+
+
+
+
+ )} + + + + {/* Time Spent */} + + + Time Spent + + +
{formatDuration(global.total_duration_ms)}
+
+ {formatDuration(global.avg_duration_ms_per_task)} avg per task +
+
+
+
{global.total_turns}
+
Total turns
+
+
+
{global.total_messages}
+
Total messages
+
+
+
+
+ + {/* Git Stats or Averages */} + {hasGitStats ? ( + + + + + Git Activity + + + +
{git_stats.total_commits}
+
+ {git_stats.total_files_changed} files changed +
+
+ + +{git_stats.total_insertions.toLocaleString()} + + + −{git_stats.total_deletions.toLocaleString()} + +
+
+
+ ) : ( + + + Averages + + +
+
+ Turns per task + {global.avg_turns_per_task.toFixed(1)} +
+
+ Messages per task + {global.avg_messages_per_task.toFixed(1)} +
+
+ Sessions + {global.total_sessions} +
+
+
+
+ )} + + {/* Signal */} + + + Signal + + +
+ {global.total_sessions} +
+
+ {avgTurnsPerSession.toFixed(1)} turns · {avgMessagesPerSession.toFixed(1)} messages per session +
+
+
+
+ User msgs + {global.total_user_messages} +
+
+ User share + {formatPercent(userShare)} +
+
+
+
+ Tool calls + {global.total_tool_calls} +
+
+ Tool share + {formatPercent(toolShare)} +
+
+
+
+
+
+ +
+
Telemetry
+
+
+ +
+ {/* Completed Tasks and Productivity */} +
+ + + + Completed Tasks Over Time + + + + + + + + + + + Most Productive + + + + + + +
+
+ + {/* Activity and Agents Row */} +
+ + + + Activity ({rangeLabel.toLowerCase()}) + + + + + + + + + + Top Agents + + + + + +
+ + {/* Repositories */} + + + Repository Activity + + + + + + + + + Top Repositories + + + + + + + + + Repo Leaders + + + + + + +
+
Workload
+
+
+ + {/* Top Tasks by Duration */} + {task_stats.length > 0 && ( +
+ {/* Longest Tasks (Most Complex) */} + + + + Longest Tasks + + + +
+ {[...task_stats] + .filter(t => t.total_duration_ms > 0) + .sort((a, b) => b.total_duration_ms - a.total_duration_ms) + .slice(0, 3) + .map((task, idx) => ( +
+ {idx + 1}. +
+
+ {task.task_title} +
+
+ {task.turn_count} turns · {task.message_count} messages +
+
+
+ {formatDuration(task.total_duration_ms)} +
+
+ ))} + {task_stats.filter(t => t.total_duration_ms > 0).length === 0 && ( +
No completed tasks yet.
+ )} +
+
+
+ + {/* Quickest Tasks */} + + + + Quickest Tasks + + + +
+ {[...task_stats] + .filter(t => t.total_duration_ms > 0) + .sort((a, b) => a.total_duration_ms - b.total_duration_ms) + .slice(0, 3) + .map((task, idx) => ( +
+ {idx + 1}. +
+
+ {task.task_title} +
+
+ {task.turn_count} turns · {task.message_count} messages +
+
+
+ {formatDuration(task.total_duration_ms)} +
+
+ ))} + {task_stats.filter(t => t.total_duration_ms > 0).length === 0 && ( +
No completed tasks yet.
+ )} +
+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index abbdcb9747..048917bd8d 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { Button } from '@kandev/ui/button'; import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip'; -import { IconPlus, IconSettings, IconList, IconLayoutKanban, IconMenu2 } from '@tabler/icons-react'; +import { IconPlus, IconSettings, IconList, IconLayoutKanban, IconMenu2, IconChartBar } from '@tabler/icons-react'; import { KanbanDisplayDropdown } from '../kanban-display-dropdown'; import { TaskSearchInput } from './task-search-input'; import { KanbanHeaderMobile } from './kanban-header-mobile'; @@ -189,6 +189,16 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban' + + + + + Stats + diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 965e6882b4..8304eca056 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -19,7 +19,7 @@ import { SelectValue, } from '@kandev/ui/select'; import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group'; -import { IconSettings, IconList, IconLayoutKanban } from '@tabler/icons-react'; +import { IconSettings, IconList, IconLayoutKanban, IconChartBar } from '@tabler/icons-react'; import { TaskSearchInput } from './task-search-input'; import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings'; import { linkToTasks } from '@/lib/links'; @@ -207,12 +207,20 @@ export function MobileMenuSheet({
- onOpenChange(false)}> - - +
+ onOpenChange(false)}> + + + onOpenChange(false)}> + + +
diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 4446773d4a..b3a434060d 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -12,6 +12,7 @@ import { IconCode, IconCpu, IconMessageCircle, + IconChartBar, } from '@tabler/icons-react'; import { Sidebar, @@ -120,6 +121,7 @@ export function SettingsAppSidebar() { const workspacePath = `/settings/workspace/${workspace.id}`; const boardsPath = `${workspacePath}/boards`; const repositoriesPath = `${workspacePath}/repositories`; + const statsPath = `${workspacePath}/stats`; return ( @@ -154,6 +156,18 @@ export function SettingsAppSidebar() { + + + + + Stats + + + ); diff --git a/apps/web/lib/api/domains/stats-api.ts b/apps/web/lib/api/domains/stats-api.ts new file mode 100644 index 0000000000..e312c800d5 --- /dev/null +++ b/apps/web/lib/api/domains/stats-api.ts @@ -0,0 +1,13 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import type { StatsResponse } from '@/lib/types/http'; + +export type StatsRange = 'week' | 'month'; + +export async function fetchStats( + workspaceId: string, + options?: ApiRequestOptions, + range?: StatsRange +) { + const query = range ? `?range=${encodeURIComponent(range)}` : ''; + return fetchJson(`/api/v1/workspaces/${workspaceId}/stats${query}`, options); +} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 465326191a..0434c01920 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -696,3 +696,91 @@ export type TaskPlan = { export type TaskPlanResponse = { plan: TaskPlan | null; }; + +// Stats types +export type TaskStatsDTO = { + task_id: string; + task_title: string; + workspace_id: string; + board_id: string; + state: string; + session_count: number; + turn_count: number; + message_count: number; + user_message_count: number; + tool_call_count: number; + total_duration_ms: number; + created_at: string; + completed_at?: string; +}; + +export type GlobalStatsDTO = { + total_tasks: number; + completed_tasks: number; + in_progress_tasks: number; + total_sessions: number; + total_turns: number; + total_messages: number; + total_user_messages: number; + total_tool_calls: number; + total_duration_ms: number; + avg_turns_per_task: number; + avg_messages_per_task: number; + avg_duration_ms_per_task: number; +}; + +export type DailyActivityDTO = { + date: string; + turn_count: number; + message_count: number; + task_count: number; +}; + +export type CompletedTaskActivityDTO = { + date: string; + completed_tasks: number; +}; + +export type AgentUsageDTO = { + agent_profile_id: string; + agent_profile_name: string; + agent_model: string; + session_count: number; + turn_count: number; + total_duration_ms: number; +}; + +export type RepositoryStatsDTO = { + repository_id: string; + repository_name: string; + total_tasks: number; + completed_tasks: number; + in_progress_tasks: number; + session_count: number; + turn_count: number; + message_count: number; + user_message_count: number; + tool_call_count: number; + total_duration_ms: number; + total_commits: number; + total_files_changed: number; + total_insertions: number; + total_deletions: number; +}; + +export type GitStatsDTO = { + total_commits: number; + total_files_changed: number; + total_insertions: number; + total_deletions: number; +}; + +export type StatsResponse = { + global: GlobalStatsDTO; + task_stats: TaskStatsDTO[]; + daily_activity: DailyActivityDTO[]; + completed_activity: CompletedTaskActivityDTO[]; + agent_usage: AgentUsageDTO[]; + repository_stats: RepositoryStatsDTO[]; + git_stats: GitStatsDTO; +};