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 (
+
Select a workspace to view statistics.
+Error loading stats: {error}
+No stats available.
+