feat: improved stats (#227)

This commit is contained in:
Carlos Florêncio
2026-02-16 12:00:54 +00:00
committed by GitHub
parent 212d23558a
commit fc1c328e4a
12 changed files with 729 additions and 24 deletions
+7 -5
View File
@@ -31,6 +31,13 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
}
cleanups = append(cleanups, cleanup)
// Workflow repo must be initialized before analytics repo because
// analytics creates indexes on the workflow_steps table.
workflowRepo, err := workflowrepository.NewWithDB(dbConn)
if err != nil {
return nil, nil, nil, err
}
analyticsRepo, cleanup, err := analyticsrepository.Provide(dbConn)
if err != nil {
return nil, nil, nil, err
@@ -67,11 +74,6 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
}
cleanups = append(cleanups, cleanup)
workflowRepo, err := workflowrepository.NewWithDB(dbConn)
if err != nil {
return nil, nil, nil, err
}
repos := &Repositories{
Task: taskRepoImpl,
Analytics: analyticsRepo,
@@ -12,6 +12,9 @@ import (
"go.uber.org/zap"
)
// allTimeActivityDays is the number of days shown in the daily activity heatmap for the "all" range.
const allTimeActivityDays = 365
type StatsHandlers struct {
repo repository.Repository
logger *logger.Logger
@@ -213,6 +216,8 @@ func parseStatsRange(rangeKey string) (*time.Time, int) {
case "month":
start := now.AddDate(0, 0, -30)
return &start, 30
case "all":
return nil, allTimeActivityDays
default:
start := now.AddDate(0, 0, -30)
return &start, 30
@@ -0,0 +1,72 @@
package handlers
import (
"testing"
"time"
)
func TestParseStatsRange(t *testing.T) {
tests := []struct {
name string
rangeKey string
wantNilStart bool
wantDays int
}{
{
name: "week returns 7 days",
rangeKey: "week",
wantNilStart: false,
wantDays: 7,
},
{
name: "month returns 30 days",
rangeKey: "month",
wantNilStart: false,
wantDays: 30,
},
{
name: "all returns nil start and allTimeActivityDays",
rangeKey: "all",
wantNilStart: true,
wantDays: allTimeActivityDays,
},
{
name: "empty string defaults to month",
rangeKey: "",
wantNilStart: false,
wantDays: 30,
},
{
name: "unknown value defaults to month",
rangeKey: "year",
wantNilStart: false,
wantDays: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, days := parseStatsRange(tt.rangeKey)
if tt.wantNilStart {
if start != nil {
t.Errorf("expected nil start, got %v", start)
}
} else {
if start == nil {
t.Fatal("expected non-nil start, got nil")
}
// Verify start is approximately the right number of days ago
expectedStart := time.Now().UTC().AddDate(0, 0, -tt.wantDays)
diff := start.Sub(expectedStart)
if diff < -time.Second || diff > time.Second {
t.Errorf("expected start ~%v, got %v (diff: %v)", expectedStart, start, diff)
}
}
if days != tt.wantDays {
t.Errorf("expected days=%d, got %d", tt.wantDays, days)
}
})
}
}
@@ -9,11 +9,88 @@ type Repository struct {
}
// NewWithDB creates a new analytics repository with an existing database connection.
// It automatically creates performance indexes for stats queries.
func NewWithDB(dbConn *sql.DB) (*Repository, error) {
return &Repository{db: dbConn}, nil
repo := &Repository{db: dbConn}
if err := repo.ensureStatsIndexes(); err != nil {
return nil, err
}
return repo, nil
}
// Close is a no-op because this repository does not own the database connection.
func (r *Repository) Close() error {
return nil
}
// ensureStatsIndexes creates performance indexes for stats queries if they don't already exist.
// Query planner statistics are maintained by PRAGMA optimize on connection close (see persistence/provider.go).
func (r *Repository) ensureStatsIndexes() error {
indexes := []string{
// Tasks table indexes
`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_created
ON tasks(workspace_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_state
ON tasks(workflow_step_id, state)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_updated
ON tasks(workspace_id, updated_at)`,
// Task sessions indexes
`CREATE INDEX IF NOT EXISTS idx_sessions_task_started
ON task_sessions(task_id, started_at)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_agent_started
ON task_sessions(agent_profile_id, started_at)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_repo
ON task_sessions(repository_id)`,
// Task session turns indexes
`CREATE INDEX IF NOT EXISTS idx_turns_session_times
ON task_session_turns(task_session_id, started_at, completed_at)`,
`CREATE INDEX IF NOT EXISTS idx_turns_task
ON task_session_turns(task_id)`,
// Task session messages indexes
`CREATE INDEX IF NOT EXISTS idx_messages_session_created
ON task_session_messages(task_session_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_author_type
ON task_session_messages(task_session_id, author_type, type)`,
`CREATE INDEX IF NOT EXISTS idx_messages_turn
ON task_session_messages(turn_id)`,
// Task session commits indexes
`CREATE INDEX IF NOT EXISTS idx_commits_session_time
ON task_session_commits(session_id, committed_at)`,
// Task repositories indexes
`CREATE INDEX IF NOT EXISTS idx_task_repos_task
ON task_repositories(task_id, repository_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_repos_repo
ON task_repositories(repository_id, task_id)`,
// Repositories indexes (with soft delete filter)
`CREATE INDEX IF NOT EXISTS idx_repos_workspace
ON repositories(workspace_id)
WHERE deleted_at IS NULL`,
// Workflow steps indexes
`CREATE INDEX IF NOT EXISTS idx_workflow_steps_type
ON workflow_steps(id, step_type)`,
}
for _, idx := range indexes {
if _, err := r.db.Exec(idx); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,289 @@
package sqlite
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"github.com/kandev/kandev/internal/db"
)
// createTestDB sets up a SQLite database with all required schemas for analytics queries.
func createTestDB(t *testing.T) *sql.DB {
t.Helper()
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
t.Cleanup(func() { _ = dbConn.Close() })
// Create tables that analytics queries depend on
schema := `
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
name TEXT NOT NULL,
step_type TEXT NOT NULL,
position INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
board_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
state TEXT DEFAULT 'TODO',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'local',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
);
CREATE TABLE IF NOT EXISTS task_repositories (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(task_id, repository_id)
);
CREATE TABLE IF NOT EXISTS task_sessions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_profile_id TEXT NOT NULL,
agent_profile_snapshot TEXT DEFAULT '{}',
repository_id TEXT DEFAULT '',
state TEXT NOT NULL DEFAULT 'CREATED',
started_at DATETIME NOT NULL,
completed_at DATETIME,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_turns (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_messages (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
author_type TEXT NOT NULL DEFAULT 'user',
type TEXT NOT NULL DEFAULT 'message',
content TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS task_session_commits (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
commit_sha TEXT NOT NULL,
committed_at DATETIME NOT NULL,
files_changed INTEGER DEFAULT 0,
insertions INTEGER DEFAULT 0,
deletions INTEGER DEFAULT 0,
created_at DATETIME NOT NULL
);
`
if _, err := dbConn.Exec(schema); err != nil {
t.Fatalf("failed to create schema: %v", err)
}
return dbConn
}
func execOrFatal(t *testing.T, dbConn *sql.DB, query string, args ...any) {
t.Helper()
if _, err := dbConn.Exec(query, args...); err != nil {
t.Fatalf("exec failed: %v", err)
}
}
func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
// Verify indexes were created
var count int
err = dbConn.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'`,
).Scan(&count)
if err != nil {
t.Fatalf("failed to count indexes: %v", err)
}
if count == 0 {
t.Error("expected indexes to be created, got 0")
}
_ = repo // keep reference
}
func TestEnsureStatsIndexes_Idempotent(t *testing.T) {
dbConn := createTestDB(t)
repo := &Repository{db: dbConn}
if err := repo.ensureStatsIndexes(); err != nil {
t.Fatalf("first ensureStatsIndexes failed: %v", err)
}
// Calling again should not error (CREATE INDEX IF NOT EXISTS is idempotent)
if err := repo.ensureStatsIndexes(); err != nil {
t.Fatalf("second ensureStatsIndexes failed: %v", err)
}
}
func TestGetRepositoryStats_ExcludesSoftDeletedRepos(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
ctx := context.Background()
now := time.Now().UTC().Format(time.RFC3339)
// Insert workspace
if _, err := dbConn.Exec(
`INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
"ws-1", "Test Workspace", now, now,
); err != nil {
t.Fatalf("failed to insert workspace: %v", err)
}
// Insert an active repo
if _, err := dbConn.Exec(
`INSERT INTO repositories (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
"repo-active", "ws-1", "active-repo", now, now,
); err != nil {
t.Fatalf("failed to insert active repo: %v", err)
}
// Insert a soft-deleted repo
if _, err := dbConn.Exec(
`INSERT INTO repositories (id, workspace_id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
"repo-deleted", "ws-1", "deleted-repo", now, now, now,
); err != nil {
t.Fatalf("failed to insert deleted repo: %v", err)
}
results, err := repo.GetRepositoryStats(ctx, "ws-1", nil)
if err != nil {
t.Fatalf("GetRepositoryStats failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 repository, got %d", len(results))
}
if results[0].RepositoryID != "repo-active" {
t.Errorf("expected repo-active, got %s", results[0].RepositoryID)
}
}
func TestGetGlobalStats_EmptyWorkspace(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
ctx := context.Background()
stats, err := repo.GetGlobalStats(ctx, "nonexistent", nil)
if err != nil {
t.Fatalf("GetGlobalStats failed: %v", err)
}
if stats.TotalTasks != 0 {
t.Errorf("expected 0 total tasks, got %d", stats.TotalTasks)
}
if stats.TotalSessions != 0 {
t.Errorf("expected 0 total sessions, got %d", stats.TotalSessions)
}
}
func TestGetGlobalStats_WithTimeFilter(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
ctx := context.Background()
now := time.Now().UTC()
nowStr := now.Format(time.RFC3339)
oldStr := now.AddDate(0, 0, -60).Format(time.RFC3339)
// Insert workspace + board + workflow step
execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO workflow_steps (id, board_id, name, step_type, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 'todo', 0, ?, ?)`, nowStr, nowStr)
// Insert a recent task and an old task
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-recent', 'ws-1', 'board-1', 'step-todo', 'Recent', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-old', 'ws-1', 'board-1', 'step-todo', 'Old', ?, ?)`, oldStr, oldStr)
// With no time filter — should see both
stats, err := repo.GetGlobalStats(ctx, "ws-1", nil)
if err != nil {
t.Fatalf("GetGlobalStats (no filter) failed: %v", err)
}
if stats.TotalTasks != 2 {
t.Errorf("expected 2 total tasks (no filter), got %d", stats.TotalTasks)
}
// With time filter for last 7 days — should see only the recent one
weekAgo := now.AddDate(0, 0, -7)
stats, err = repo.GetGlobalStats(ctx, "ws-1", &weekAgo)
if err != nil {
t.Fatalf("GetGlobalStats (week filter) failed: %v", err)
}
if stats.TotalTasks != 1 {
t.Errorf("expected 1 total task (week filter), got %d", stats.TotalTasks)
}
}
func TestGetGitStats_EmptyWorkspace(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
ctx := context.Background()
stats, err := repo.GetGitStats(ctx, "nonexistent", nil)
if err != nil {
t.Fatalf("GetGitStats failed: %v", err)
}
if stats.TotalCommits != 0 {
t.Errorf("expected 0 commits, got %d", stats.TotalCommits)
}
}
@@ -410,6 +410,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string,
GROUP BY s.repository_id
) git_stats ON git_stats.repository_id = r.id
WHERE r.workspace_id = ?
AND r.deleted_at IS NULL
ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC
`
@@ -35,7 +35,14 @@ func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, err
if log != nil {
log.Info("Database initialized", zap.String("db_path", dbPath), zap.String("db_driver", driver))
}
return dbConn, func() error { return dbConn.Close() }, nil
cleanup := func() error {
// Run PRAGMA optimize before closing to update query planner
// statistics for tables that need it. This is the SQLite-recommended
// way to maintain stats — lightweight and safe to call on every close.
_, _ = dbConn.Exec("PRAGMA optimize")
return dbConn.Close()
}
return dbConn, cleanup, nil
default:
return nil, nil, fmt.Errorf("unsupported database driver: %s", driver)
}
+154
View File
@@ -0,0 +1,154 @@
import { Card, CardContent, CardHeader } from '@kandev/ui/card';
import { Skeleton } from '@kandev/ui/skeleton';
export default function StatsLoading() {
return (
<div className="h-screen w-full flex flex-col bg-background">
{/* Header */}
<header className="flex items-center gap-3 p-4 pb-3 shrink-0">
<Skeleton className="h-8 w-16" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-48" />
</div>
<div className="ml-auto flex items-center gap-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-7 w-24" />
</div>
</header>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto p-6">
<div className="space-y-5">
{/* Overview Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="rounded-sm">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-9 w-16 mb-2" />
<Skeleton className="h-4 w-32 mb-3" />
<Skeleton className="h-1.5 w-full" />
</CardContent>
</Card>
))}
</div>
{/* Telemetry Section */}
<Skeleton className="h-4 w-32" />
{/* Completed Tasks and Productivity */}
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-sm lg:col-span-2">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex gap-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-7 w-16" />
))}
</div>
<Skeleton className="h-32 w-full" />
<div className="flex justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Activity and Agents */}
<div className="grid gap-4 lg:grid-cols-2">
<Card className="rounded-sm">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-48 mb-2" />
<Skeleton className="h-24 w-full" />
<div className="flex items-center gap-2 mt-2">
<Skeleton className="h-3 w-8" />
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-2 w-2" />
))}
<Skeleton className="h-3 w-8" />
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i}>
<div className="flex justify-between mb-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-8" />
</div>
<Skeleton className="h-1.5 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Repositories */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-40" />
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="rounded-sm border bg-muted/20 p-3">
<div className="flex justify-between mb-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex gap-3 mb-3">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-3 w-full mb-2" />
<Skeleton className="h-3 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
+4 -2
View File
@@ -70,7 +70,7 @@ function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
type RangeKey = 'week' | 'month';
type RangeKey = 'week' | 'month' | 'all';
function getRangeLabel(range: RangeKey): string {
switch (range) {
@@ -78,6 +78,8 @@ function getRangeLabel(range: RangeKey): string {
return 'Last Week';
case 'month':
return 'Last Month';
case 'all':
return 'All Time';
default:
return 'Last Month';
}
@@ -830,7 +832,7 @@ export function StatsPageClient({ stats, error, workspaceId, activeRange }: Stat
variant="outline"
className="h-7"
>
{(['week', 'month'] as RangeKey[]).map((key) => (
{(['week', 'month', 'all'] as RangeKey[]).map((key) => (
<ToggleGroupItem
key={key}
value={key}
@@ -12,7 +12,6 @@ import {
IconCode,
IconCpu,
IconMessageCircle,
IconChartBar,
} from '@tabler/icons-react';
import {
Sidebar,
@@ -123,7 +122,6 @@ export function SettingsAppSidebar() {
const workspacePath = `/settings/workspace/${workspace.id}`;
const boardsPath = `${workspacePath}/boards`;
const repositoriesPath = `${workspacePath}/repositories`;
const statsPath = `${workspacePath}/stats`;
return (
<SidebarMenuSubItem key={workspace.id}>
@@ -158,18 +156,6 @@ export function SettingsAppSidebar() {
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === statsPath}
>
<Link href={statsPath}>
<IconChartBar className="h-3 w-3 mr-1" />
<span>Stats</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
+1 -1
View File
@@ -1,7 +1,7 @@
import { fetchJson, type ApiRequestOptions } from '../client';
import type { StatsResponse } from '@/lib/types/http';
export type StatsRange = 'week' | 'month';
export type StatsRange = 'week' | 'month' | 'all';
export async function fetchStats(
workspaceId: string,
+110
View File
@@ -0,0 +1,110 @@
# Statistics System
## Overview
The stats system provides workspace-level analytics about tasks, agent sessions, repository activity, and git commits. It computes everything on-demand from existing tables — there are no dedicated stats tables.
**Route**: `/stats` (with `?range=week|month|all`)
## Architecture
```
Frontend Backend Database
─────── ─────── ────────
/stats (server component) GET /api/v1/workspaces/:id/ SQLite
└─ fetchStats() stats?range=week
└─ stats-page-client.tsx stats_handlers.go Source tables:
├─ Overview cards └─ httpGetStats() ├─ tasks
├─ Heatmap ├─ GetGlobalStats() ├─ task_sessions
├─ Completed chart ├─ GetTaskStats() ├─ task_session_turns
├─ Agent usage ├─ GetDailyActivity() ├─ task_session_messages
├─ Repo stats ├─ GetCompletedTaskActivity()├─ task_session_commits
└─ Workload ├─ GetAgentUsage() ├─ task_repositories
├─ GetRepositoryStats() ├─ repositories
└─ GetGitStats() └─ workflow_steps
```
## Data Flow
### Write path (automatic during task execution)
| Event | Table | Key columns |
|-------|-------|-------------|
| Session start | `task_sessions` | task_id, agent_profile_id, started_at |
| Turn start | `task_session_turns` | task_session_id, started_at |
| Message sent | `task_session_messages` | task_session_id, author_type, type |
| Git commit | `task_session_commits` | session_id, files_changed, insertions, deletions |
| Turn end | `task_session_turns` | completed_at (UPDATE) |
| Session end | `task_sessions` | completed_at, state (UPDATE) |
Write code lives in `apps/backend/internal/task/repository/sqlite/` (session.go, message.go, git_snapshots.go).
### Read path (stats page load)
The handler at `apps/backend/internal/analytics/handlers/stats_handlers.go` calls 7 repository methods sequentially, each running a SQL aggregation query against the source tables. Results are assembled into a single `StatsResponse` JSON.
## Key Files
| File | Purpose |
|------|---------|
| `apps/backend/internal/analytics/handlers/stats_handlers.go` | HTTP handler, DTO conversion |
| `apps/backend/internal/analytics/repository/sqlite/stats.go` | All SQL queries |
| `apps/backend/internal/analytics/repository/sqlite/repository.go` | Init + auto-created indexes |
| `apps/backend/internal/analytics/models/models.go` | Go data models |
| `apps/web/app/stats/page.tsx` | Server component (data fetching) |
| `apps/web/app/stats/stats-page-client.tsx` | Client component (all UI) |
| `apps/web/app/stats/loading.tsx` | Loading skeleton |
| `apps/web/lib/api/domains/stats-api.ts` | Frontend API client |
## Metrics
### How key metrics are computed
| Metric | Method |
|--------|--------|
| Completed tasks | Tasks whose workflow step has `step_type = 'done'` |
| In-progress tasks | Tasks with `state = 'IN_PROGRESS'` |
| Duration | `SUM((julianday(completed_at) - julianday(started_at)) * 86400000)` on turns |
| Tool calls | Messages with `type LIKE 'tool_%'` |
| User messages | Messages with `author_type = 'user'` |
| Agent name/model | `json_extract()` from `agent_profile_snapshot` on sessions |
| Git stats | Aggregated from `task_session_commits` (only in-session commits) |
### Time ranges
| Range | Backend behavior |
|-------|-----------------|
| `week` | `start = now - 7 days`, daily activity = 7 days |
| `month` | `start = now - 30 days`, daily activity = 30 days |
| `all` | `start = nil` (no filter), daily activity = 365 days |
Range is parsed in `parseStatsRange()`. The `start` value is passed to each query as `WHERE (? IS NULL OR timestamp >= ?)`.
## Performance
### Indexes
Performance indexes are **automatically created** on startup in `repository.go` via `ensureStatsIndexes()`. Uses `CREATE INDEX IF NOT EXISTS` so it's idempotent.
Key indexes: tasks(workspace_id, created_at), task_sessions(task_id, started_at), task_session_turns(task_session_id, started_at, completed_at), task_session_messages(task_session_id, author_type, type), task_session_commits(session_id, committed_at).
### Query planner statistics
`PRAGMA optimize` runs automatically on database connection close (see `persistence/provider.go`). This keeps SQLite's query planner statistics up to date by only re-analyzing tables whose stats are stale. No manual `ANALYZE` is needed.
### Query patterns
- Separate subqueries to avoid row multiplication from JOINs
- `COUNT(DISTINCT id)` to prevent duplicate counting across LEFT JOINs
- `COALESCE(..., 0)` for NULL safety
- Recursive CTE for date series (heatmap/charts fill in zero-activity days)
## Known Limitations
1. **No caching** — every page load runs all 7 queries
2. **No real-time updates** — single fetch on page load, no WebSocket/polling
3. **Git tracking gaps** — only commits made during task sessions are tracked
4. **Duration precision** — `julianday()` returns float, cast to int64 (minor precision loss)
5. **Incomplete turns** — turns with NULL `completed_at` count in totals but contribute 0ms to duration
6. **"Month" = 30 days** — not a calendar month
7. **Sequential queries** — the 7 repo calls in the handler run sequentially, not in parallel