feat: crush stats (#1920)

Carlos Alexandro Becker and Christian Rocha created

* wip: stats

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fixup! wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: css

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: cleanup

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* logo

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: cast

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* cleanup

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* improvements

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* provider donut

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: improvements

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* wip

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* jetbrains mono

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fixes

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: rm border

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore: update footer/header

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: footer class

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* refactor: move stuff around

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* refactor: improving

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: anims

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: rename vars

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore: remove all card borders

* chore: adjust easing

* fix: fail if no sessions

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: improvements

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: generated by

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: header hazy

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

Taskfile.yaml                   |   5 
internal/cmd/root.go            |   1 
internal/cmd/stats.go           | 384 +++++++++++++++++++++++++++++++++++
internal/cmd/stats/AGENTS.md    |   3 
internal/cmd/stats/footer.svg   |  44 ++++
internal/cmd/stats/header.svg   |  15 +
internal/cmd/stats/heartbit.svg |  43 +++
internal/cmd/stats/index.css    | 275 +++++++++++++++++++++++++
internal/cmd/stats/index.html   | 136 ++++++++++++
internal/cmd/stats/index.js     | 356 ++++++++++++++++++++++++++++++++
internal/db/db.go               |  90 ++++++++
internal/db/querier.go          |   9 
internal/db/sql/stats.sql       |  93 ++++++++
internal/db/stats.sql.go        | 367 +++++++++++++++++++++++++++++++++
14 files changed, 1,821 insertions(+)

Detailed changes

Taskfile.yaml 🔗

@@ -66,6 +66,11 @@ tasks:
     cmds:
       - gofumpt -w .
 
+  fmt:html:
+    desc: Run prettier on HTML/CSS/JS files
+    cmds:
+      - prettier --write internal/cmd/stats/index.html internal/cmd/stats/index.css internal/cmd/stats/index.js
+
   dev:
     desc: Run with profiling enabled
     env:

internal/cmd/stats.go 🔗

@@ -0,0 +1,384 @@
+package cmd
+
+import (
+	"bytes"
+	"context"
+	"database/sql"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"os"
+	"os/user"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/pkg/browser"
+	"github.com/spf13/cobra"
+)
+
+//go:embed stats/index.html
+var statsTemplate string
+
+//go:embed stats/index.css
+var statsCSS string
+
+//go:embed stats/index.js
+var statsJS string
+
+//go:embed stats/header.svg
+var headerSVG string
+
+//go:embed stats/heartbit.svg
+var heartbitSVG string
+
+//go:embed stats/footer.svg
+var footerSVG string
+
+var statsCmd = &cobra.Command{
+	Use:   "stats",
+	Short: "Show usage statistics",
+	Long:  "Generate and display usage statistics including token usage, costs, and activity patterns",
+	RunE:  runStats,
+}
+
+// Day names for day of week statistics.
+var dayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
+
+// Stats holds all the statistics data.
+type Stats struct {
+	GeneratedAt       time.Time          `json:"generated_at"`
+	Total             TotalStats         `json:"total"`
+	UsageByDay        []DailyUsage       `json:"usage_by_day"`
+	UsageByModel      []ModelUsage       `json:"usage_by_model"`
+	UsageByHour       []HourlyUsage      `json:"usage_by_hour"`
+	UsageByDayOfWeek  []DayOfWeekUsage   `json:"usage_by_day_of_week"`
+	RecentActivity    []DailyActivity    `json:"recent_activity"`
+	AvgResponseTimeMs float64            `json:"avg_response_time_ms"`
+	ToolUsage         []ToolUsage        `json:"tool_usage"`
+	HourDayHeatmap    []HourDayHeatmapPt `json:"hour_day_heatmap"`
+}
+
+type TotalStats struct {
+	TotalSessions         int64   `json:"total_sessions"`
+	TotalPromptTokens     int64   `json:"total_prompt_tokens"`
+	TotalCompletionTokens int64   `json:"total_completion_tokens"`
+	TotalTokens           int64   `json:"total_tokens"`
+	TotalCost             float64 `json:"total_cost"`
+	TotalMessages         int64   `json:"total_messages"`
+	AvgTokensPerSession   float64 `json:"avg_tokens_per_session"`
+	AvgMessagesPerSession float64 `json:"avg_messages_per_session"`
+}
+
+type DailyUsage struct {
+	Day              string  `json:"day"`
+	PromptTokens     int64   `json:"prompt_tokens"`
+	CompletionTokens int64   `json:"completion_tokens"`
+	TotalTokens      int64   `json:"total_tokens"`
+	Cost             float64 `json:"cost"`
+	SessionCount     int64   `json:"session_count"`
+}
+
+type ModelUsage struct {
+	Model        string `json:"model"`
+	Provider     string `json:"provider"`
+	MessageCount int64  `json:"message_count"`
+}
+
+type HourlyUsage struct {
+	Hour         int   `json:"hour"`
+	SessionCount int64 `json:"session_count"`
+}
+
+type DayOfWeekUsage struct {
+	DayOfWeek        int    `json:"day_of_week"`
+	DayName          string `json:"day_name"`
+	SessionCount     int64  `json:"session_count"`
+	PromptTokens     int64  `json:"prompt_tokens"`
+	CompletionTokens int64  `json:"completion_tokens"`
+}
+
+type DailyActivity struct {
+	Day          string  `json:"day"`
+	SessionCount int64   `json:"session_count"`
+	TotalTokens  int64   `json:"total_tokens"`
+	Cost         float64 `json:"cost"`
+}
+
+type ToolUsage struct {
+	ToolName  string `json:"tool_name"`
+	CallCount int64  `json:"call_count"`
+}
+
+type HourDayHeatmapPt struct {
+	DayOfWeek    int   `json:"day_of_week"`
+	Hour         int   `json:"hour"`
+	SessionCount int64 `json:"session_count"`
+}
+
+func runStats(cmd *cobra.Command, _ []string) error {
+	dataDir, _ := cmd.Flags().GetString("data-dir")
+	ctx := cmd.Context()
+
+	if dataDir == "" {
+		cfg, err := config.Init("", "", false)
+		if err != nil {
+			return fmt.Errorf("failed to initialize config: %w", err)
+		}
+		dataDir = cfg.Options.DataDirectory
+	}
+
+	conn, err := db.Connect(ctx, dataDir)
+	if err != nil {
+		return fmt.Errorf("failed to connect to database: %w", err)
+	}
+	defer conn.Close()
+
+	stats, err := gatherStats(ctx, conn)
+	if err != nil {
+		return fmt.Errorf("failed to gather stats: %w", err)
+	}
+
+	if stats.Total.TotalSessions == 0 {
+		return fmt.Errorf("no data available: no sessions found in database")
+	}
+
+	currentUser, err := user.Current()
+	if err != nil {
+		return fmt.Errorf("failed to get current user: %w", err)
+	}
+	username := currentUser.Username
+	project, err := os.Getwd()
+	if err != nil {
+		return fmt.Errorf("failed to get current directory: %w", err)
+	}
+	project = strings.Replace(project, currentUser.HomeDir, "~", 1)
+
+	htmlPath := filepath.Join(dataDir, "stats/index.html")
+	if err := generateHTML(stats, project, username, htmlPath); err != nil {
+		return fmt.Errorf("failed to generate HTML: %w", err)
+	}
+
+	fmt.Printf("Stats generated: %s\n", htmlPath)
+
+	if err := browser.OpenFile(htmlPath); err != nil {
+		fmt.Printf("Could not open browser: %v\n", err)
+		fmt.Println("Please open the file manually.")
+	}
+
+	return nil
+}
+
+func gatherStats(ctx context.Context, conn *sql.DB) (*Stats, error) {
+	queries := db.New(conn)
+
+	stats := &Stats{
+		GeneratedAt: time.Now(),
+	}
+
+	// Total stats.
+	total, err := queries.GetTotalStats(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get total stats: %w", err)
+	}
+	stats.Total = TotalStats{
+		TotalSessions:         total.TotalSessions,
+		TotalPromptTokens:     toInt64(total.TotalPromptTokens),
+		TotalCompletionTokens: toInt64(total.TotalCompletionTokens),
+		TotalTokens:           toInt64(total.TotalPromptTokens) + toInt64(total.TotalCompletionTokens),
+		TotalCost:             toFloat64(total.TotalCost),
+		TotalMessages:         toInt64(total.TotalMessages),
+		AvgTokensPerSession:   toFloat64(total.AvgTokensPerSession),
+		AvgMessagesPerSession: toFloat64(total.AvgMessagesPerSession),
+	}
+
+	// Usage by day.
+	dailyUsage, err := queries.GetUsageByDay(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get usage by day: %w", err)
+	}
+	for _, d := range dailyUsage {
+		prompt := nullFloat64ToInt64(d.PromptTokens)
+		completion := nullFloat64ToInt64(d.CompletionTokens)
+		stats.UsageByDay = append(stats.UsageByDay, DailyUsage{
+			Day:              fmt.Sprintf("%v", d.Day),
+			PromptTokens:     prompt,
+			CompletionTokens: completion,
+			TotalTokens:      prompt + completion,
+			Cost:             d.Cost.Float64,
+			SessionCount:     d.SessionCount,
+		})
+	}
+
+	// Usage by model.
+	modelUsage, err := queries.GetUsageByModel(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get usage by model: %w", err)
+	}
+	for _, m := range modelUsage {
+		stats.UsageByModel = append(stats.UsageByModel, ModelUsage{
+			Model:        m.Model,
+			Provider:     m.Provider,
+			MessageCount: m.MessageCount,
+		})
+	}
+
+	// Usage by hour.
+	hourlyUsage, err := queries.GetUsageByHour(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get usage by hour: %w", err)
+	}
+	for _, h := range hourlyUsage {
+		stats.UsageByHour = append(stats.UsageByHour, HourlyUsage{
+			Hour:         int(h.Hour),
+			SessionCount: h.SessionCount,
+		})
+	}
+
+	// Usage by day of week.
+	dowUsage, err := queries.GetUsageByDayOfWeek(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get usage by day of week: %w", err)
+	}
+	for _, d := range dowUsage {
+		stats.UsageByDayOfWeek = append(stats.UsageByDayOfWeek, DayOfWeekUsage{
+			DayOfWeek:        int(d.DayOfWeek),
+			DayName:          dayNames[int(d.DayOfWeek)],
+			SessionCount:     d.SessionCount,
+			PromptTokens:     nullFloat64ToInt64(d.PromptTokens),
+			CompletionTokens: nullFloat64ToInt64(d.CompletionTokens),
+		})
+	}
+
+	// Recent activity (last 30 days).
+	recent, err := queries.GetRecentActivity(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get recent activity: %w", err)
+	}
+	for _, r := range recent {
+		stats.RecentActivity = append(stats.RecentActivity, DailyActivity{
+			Day:          fmt.Sprintf("%v", r.Day),
+			SessionCount: r.SessionCount,
+			TotalTokens:  nullFloat64ToInt64(r.TotalTokens),
+			Cost:         r.Cost.Float64,
+		})
+	}
+
+	// Average response time.
+	avgResp, err := queries.GetAverageResponseTime(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get average response time: %w", err)
+	}
+	stats.AvgResponseTimeMs = toFloat64(avgResp) * 1000
+
+	// Tool usage.
+	toolUsage, err := queries.GetToolUsage(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get tool usage: %w", err)
+	}
+	for _, t := range toolUsage {
+		if name, ok := t.ToolName.(string); ok && name != "" {
+			stats.ToolUsage = append(stats.ToolUsage, ToolUsage{
+				ToolName:  name,
+				CallCount: t.CallCount,
+			})
+		}
+	}
+
+	// Hour/day heatmap.
+	heatmap, err := queries.GetHourDayHeatmap(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get hour day heatmap: %w", err)
+	}
+	for _, h := range heatmap {
+		stats.HourDayHeatmap = append(stats.HourDayHeatmap, HourDayHeatmapPt{
+			DayOfWeek:    int(h.DayOfWeek),
+			Hour:         int(h.Hour),
+			SessionCount: h.SessionCount,
+		})
+	}
+
+	return stats, nil
+}
+
+func toInt64(v any) int64 {
+	switch val := v.(type) {
+	case int64:
+		return val
+	case float64:
+		return int64(val)
+	case int:
+		return int64(val)
+	default:
+		return 0
+	}
+}
+
+func toFloat64(v any) float64 {
+	switch val := v.(type) {
+	case float64:
+		return val
+	case int64:
+		return float64(val)
+	case int:
+		return float64(val)
+	default:
+		return 0
+	}
+}
+
+func nullFloat64ToInt64(n sql.NullFloat64) int64 {
+	if n.Valid {
+		return int64(n.Float64)
+	}
+	return 0
+}
+
+func generateHTML(stats *Stats, projName, username, path string) error {
+	statsJSON, err := json.Marshal(stats)
+	if err != nil {
+		return err
+	}
+
+	tmpl, err := template.New("stats").Parse(statsTemplate)
+	if err != nil {
+		return fmt.Errorf("parse template: %w", err)
+	}
+
+	data := struct {
+		StatsJSON   template.JS
+		CSS         template.CSS
+		JS          template.JS
+		Header      template.HTML
+		Heartbit    template.HTML
+		Footer      template.HTML
+		GeneratedAt string
+		ProjectName string
+		Username    string
+	}{
+		StatsJSON:   template.JS(statsJSON),
+		CSS:         template.CSS(statsCSS),
+		JS:          template.JS(statsJS),
+		Header:      template.HTML(headerSVG),
+		Heartbit:    template.HTML(heartbitSVG),
+		Footer:      template.HTML(footerSVG),
+		GeneratedAt: stats.GeneratedAt.Format("2006-01-02"),
+		ProjectName: projName,
+		Username:    username,
+	}
+
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, data); err != nil {
+		return fmt.Errorf("execute template: %w", err)
+	}
+
+	// Ensure parent directory exists.
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return fmt.Errorf("create directory: %w", err)
+	}
+
+	return os.WriteFile(path, buf.Bytes(), 0o644)
+}

internal/cmd/stats/footer.svg 🔗

@@ -0,0 +1,838 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" version="1.1" viewBox="0 0 829.8 30">
+  <!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136)  -->
+  <defs>
+    <style>
+      .st0 {
+        fill: #858392;
+      }
+
+      .st1 {
+        fill: url(#linear-gradient);
+      }
+
+      .st2 {
+        fill: #fffaf1;
+      }
+    </style>
+    <linearGradient id="linear-gradient" x1="65.4" y1="14.8" x2="564.9" y2="14.8" gradientTransform="translate(0 32) scale(1 -1)" gradientUnits="userSpaceOnUse">
+      <stop offset="0" stop-color="#6b50ff"/>
+      <stop offset="1" stop-color="#ff6daa"/>
+    </linearGradient>
+  </defs>
+  <g>
+    <path class="st2" d="M738,22.7c1.2,0,2.2-.5,3.1-1.2.5-.4,1-.3,1.2.2,0,.2,0,.4,0,.6,0,2.2-1.8,4.8-6.2,4.8s-6.1-1.6-5.7-6.5l.4-4.9c.3-3.1,1.4-5.7,3.7-7.2,1.2-.8,2.7-1.2,4.6-1.2,2.9,0,3.8,1.2,3.8,2.4s-.4,1.6-1,2.1c0,0-.2,0-.3,0-.5,0-1-.1-1.5-.1-1.1,0-2,.2-2.7.9-.6.5-1,1.4-1.1,2.5l-.5,5.1c-.1,1.4.3,2.5,2,2.5h0Z"/>
+    <path class="st2" d="M759,11.5l-1.1,12.4c0,1-.4,1.8-1.2,2.3-1.1.7-2.2,1-3.2,1s-1.4-.6-1.4-1.3l1.1-12.4c0-.7-.1-1-.7-1s-.9.1-1.2.3c-.6.3-.8.7-.9,1.3l-.9,9.9c0,1.1-.6,1.8-1.3,2.3-1,.7-2.2.9-3.1.9s-1.3-.6-1.2-1.6l1.9-22.2c0-1,.4-1.9,1.2-2.4,1.1-.6,2.2-.9,3.2-.9s1.5.5,1.4,1.2c0,0-.8,8.1-.8,8.2,0,.1.2.4.4.1.3-.4.9-1,1.4-1.3.9-.6,1.9-.9,3-.9,2.4,0,3.6,1.3,3.3,4.2h0Z"/>
+    <path class="st2" d="M788.2,12.3c0,.1-.3.4-.5.3-.5-.1-1-.1-1.4-.1-1.8,0-3.2,1.1-3.3,2.9l-.7,8.5c0,1.1-.6,1.8-1.3,2.3-1,.7-2.2.9-3.1.9s-1.3-.6-1.2-1.6l1.3-14.9c0-1,.4-1.9,1.2-2.4,1.1-.6,2.1-.9,3.1-.9s1.3.5,1.3,1.2v1.3c-.1,0-.1.2,0,.2s.2-.1.3-.2c.5-1.2,1.6-2.5,3.3-2.5s2,.9,2,2.1-.3,1.9-.8,2.9h0Z"/>
+    <path class="st2" d="M810.1,26.1c-1.1.7-2.2,1-3.2,1s-1.4-.6-1.4-1.3l1.1-12.9c0-.7,0-1-.6-1s-.8.1-1.1.3c-.5.3-.8.8-.9,1.3l-.9,10.3c0,1-.4,1.8-1.2,2.3-1.1.7-2.2,1-3.2,1s-1.4-.6-1.4-1.3l1.1-12.9c0-.7,0-1-.6-1s-.9.1-1.1.3c-.6.3-.8.7-.9,1.3l-.9,10.5c0,1.1-.6,1.8-1.3,2.3-1,.7-2.2.9-3.1.9s-1.3-.6-1.2-1.6l1.3-14.9c0-1,.4-1.9,1.2-2.4,1.1-.6,2.1-.9,3.1-.9s1.3.5,1.3,1.2c0,0,0,.7,0,.9,0,.2.2.3.4.1.2-.2.7-.8,1-1,1-.8,2.1-1.2,3.4-1.2,2.2,0,2.9,1,3.2,2.1,0,.2.3.2.4,0,.2-.2.8-.8,1.2-1.1,1-.7,2.1-1.1,3.3-1.1,2.4,0,3.5,1.3,3.3,4.2l-1.1,12.4c0,1-.4,1.8-1.2,2.3h0Z"/>
+    <path class="st2" d="M824.4,30h-11.1c-.8,0-1.4-.4-1.4-1.3,0-1.6.5-2.6,1.7-2.6h11.1c.9,0,1.4.4,1.4,1.3,0,1.6-.5,2.6-1.7,2.6h0Z"/>
+    <path class="st2" d="M773.8,7.6c-.7-.2-1.9-.3-3.2-.3-2.5,0-4.6.8-6.3,1.9-1.8,1.3-3,3.2-3.3,6.6l-.5,5.8c-.2,2.8.7,5.5,3.8,5.5s2.7-.7,3.4-1.5c.3-.3.5-.7.7-1s.4-.2.4.1c0,.3-.1,1.2-.1,1.2,0,.7.3,1.2,1.3,1.2s1.9-.3,3-1c.8-.5,1.1-1.2,1.2-2.3l1.2-14.4c0-.9-.2-1.4-1.6-1.8h0ZM768.8,21.4c0,.5-.3.9-.6,1.2-.3.2-.7.4-1.1.4-.7,0-1.1-.4-1-1.3l.7-7.6c0-.8.3-1.5.7-1.9.4-.5.9-.7,1.6-.7s.6,0,.7,0l-.9,10h0Z"/>
+    <path class="st2" d="M826,23h1.5v.4h-.6v1.6h-.4v-1.6h-.5v-.4ZM827.8,23h.5l.5,1.5.5-1.5h.5v2h-.4v-.4c0-.2,0-.5,0-.8l-.4,1.1h-.4l-.4-1.1c0,.3,0,.5,0,.8v.4h-.4v-2h0Z"/>
+  </g>
+  <g>
+    <path class="st0" d="M4.6,22.6c0-.2-.1-.3-.2-.5,0-.1-.2-.3-.3-.4s-.3-.2-.4-.2c-.2,0-.3,0-.5,0-.3,0-.7,0-.9.3s-.5.4-.6.8-.2.8-.2,1.2,0,.9.2,1.3c.2.3.4.6.6.8s.6.3,1,.3.6,0,.9-.2c.2-.1.4-.3.6-.5.1-.2.2-.5.2-.8h.3s-1.8,0-1.8,0v-.9h2.7v.8c0,.6-.1,1-.4,1.5s-.6.7-1,.9-.9.3-1.4.3-1.1-.1-1.6-.4-.8-.7-1.1-1.2c-.2-.5-.4-1.1-.4-1.8s0-1,.2-1.4c.1-.4.4-.8.6-1.1.3-.3.6-.5.9-.7.4-.1.8-.2,1.2-.2s.7,0,1,.2.6.2.8.5c.2.2.4.4.6.7s.3.6.3.9h-1.2Z"/>
+    <path class="st0" d="M9.1,27.2c-.5,0-.9-.1-1.3-.3-.4-.2-.6-.5-.8-.9s-.3-.8-.3-1.3,0-1,.3-1.3.5-.7.8-.9.8-.3,1.2-.3.6,0,.9.1c.3.1.5.2.7.5s.4.5.5.8c.1.3.2.7.2,1.1v.4h-4v-.8h2.9c0-.2,0-.4-.1-.6s-.2-.3-.4-.4-.4-.1-.6-.1-.4,0-.6.2-.3.3-.4.5-.2.4-.2.6v.7c0,.3,0,.5.2.7.1.2.2.4.4.5s.4.2.7.2.3,0,.5,0,.2-.1.4-.2c.1,0,.2-.2.2-.4h1.1c0,.4-.2.7-.4.9-.2.2-.4.4-.7.5-.3.1-.6.2-1,.2h0Z"/>
+    <path class="st0" d="M13.4,24.2v2.9h-1.2v-4.9h1.1v.8h0c.1-.3.3-.5.5-.7.2-.2.6-.2.9-.2s.6,0,.9.2c.2.1.5.4.6.6.1.3.2.6.2,1v3.1h-1.2v-3c0-.3,0-.6-.2-.8s-.4-.3-.7-.3-.4,0-.5.1c-.2,0-.3.2-.4.4,0,.2-.1.4-.1.6h0Z"/>
+    <path class="st0" d="M19.9,27.2c-.5,0-.9-.1-1.3-.3-.4-.2-.6-.5-.8-.9s-.3-.8-.3-1.3.1-1,.3-1.3.5-.7.8-.9.8-.3,1.2-.3.6,0,.9.1.5.2.7.5.4.5.5.8c.1.3.2.7.2,1.1v.4h-4v-.8h2.9c0-.2,0-.4-.1-.6s-.2-.3-.4-.4-.4-.1-.6-.1-.4,0-.6.2-.3.3-.4.5-.1.4-.1.6v.7c0,.3,0,.5.2.7.1.2.2.4.4.5s.4.2.7.2.3,0,.5,0,.2-.1.4-.2c.1,0,.2-.2.2-.4h1.1c0,.4-.2.7-.4.9-.2.2-.4.4-.7.5-.3.1-.6.2-1,.2h0Z"/>
+    <path class="st0" d="M23.1,27.1v-4.9h1.1v.8h0c0-.3.2-.5.5-.7.2-.1.5-.2.8-.2h.2c0,0,.1,0,.2,0v1.1s-.1,0-.2,0-.2,0-.3,0c-.2,0-.4,0-.6.1-.2,0-.3.2-.4.4s-.1.3-.1.5v2.9h-1.2,0Z"/>
+    <path class="st0" d="M28,27.2c-.3,0-.6,0-.8-.2s-.4-.3-.6-.5c-.1-.2-.2-.5-.2-.8s0-.5.1-.7.2-.3.4-.4c.2-.1.4-.2.6-.2s.5-.1.7-.1c.3,0,.5,0,.7,0s.3,0,.4-.1c0,0,.1-.1.1-.2h0c0-.3,0-.5-.2-.6s-.4-.2-.6-.2-.5,0-.7.2-.3.3-.3.4h-1.1c0-.4.2-.7.4-.9.2-.2.4-.4.7-.5s.6-.1.9-.1.5,0,.7,0c.2,0,.5.1.6.3.2.1.4.3.5.5.1.2.2.5.2.8v3.3h-1.1v-.7h0c0,.1-.2.3-.3.4-.1.1-.3.2-.5.3-.2,0-.4.1-.7.1h0ZM28.3,26.3c.2,0,.4,0,.6-.1s.3-.2.4-.4c0-.1.1-.3.1-.5v-.6s-.1,0-.2,0c0,0-.2,0-.3,0s-.2,0-.3,0c-.1,0-.2,0-.3,0-.2,0-.3,0-.5.1-.1,0-.2.1-.3.2s-.1.2-.1.4,0,.4.2.5c.2.1.4.2.6.2h0Z"/>
+    <path class="st0" d="M34.2,22.2v.9h-2.8v-.9h2.8ZM32,21h1.2v4.6c0,.2,0,.3,0,.4s.1.1.2.2c0,0,.2,0,.3,0s.1,0,.2,0,.1,0,.1,0l.2.9c0,0-.2,0-.3,0s-.2,0-.4,0c-.3,0-.5,0-.8-.1-.2-.1-.4-.2-.5-.5-.1-.2-.2-.5-.2-.8v-4.8h0Z"/>
+    <path class="st0" d="M37.3,27.2c-.5,0-.9-.1-1.3-.3-.4-.2-.6-.5-.8-.9s-.3-.8-.3-1.3,0-1,.3-1.3.5-.7.8-.9c.3-.2.8-.3,1.2-.3s.6,0,.9.1.5.2.7.5c.2.2.4.5.5.8.1.3.2.7.2,1.1v.4h-4v-.8h2.9c0-.2,0-.4-.1-.6s-.2-.3-.4-.4-.4-.1-.6-.1-.4,0-.6.2-.3.3-.4.5-.2.4-.2.6v.7c0,.3,0,.5.2.7,0,.2.2.4.4.5s.4.2.7.2.3,0,.5,0,.2-.1.3-.2c0,0,.2-.2.2-.4h1.1c0,.4-.2.7-.4.9-.2.2-.4.4-.7.5-.3.1-.6.2-1,.2h0Z"/>
+    <path class="st0" d="M42.2,27.2c-.4,0-.7-.1-1-.3s-.5-.5-.7-.9c-.2-.4-.3-.8-.3-1.4s0-1,.3-1.4.4-.7.7-.9.7-.3,1-.3.5,0,.7.1.3.2.4.4c.1.1.2.3.2.4h0v-2.5h1.2v6.5h-1.1v-.8h0c0,.1-.2.2-.3.4-.1.1-.3.2-.5.3-.2,0-.4.1-.7.1h0ZM42.6,26.2c.2,0,.5,0,.6-.2.2-.1.3-.3.4-.6s.1-.5.1-.8,0-.6-.1-.8-.2-.4-.4-.5-.4-.2-.6-.2-.5,0-.7.2c-.2.1-.3.3-.4.6s-.1.5-.1.8,0,.6.1.8.2.4.4.6.4.2.6.2h0Z"/>
+    <path class="st0" d="M48.4,27.1v-6.5h1.2v2.5h0c0-.1.1-.2.2-.4s.3-.2.4-.4c.2-.1.4-.1.7-.1s.7.1,1,.3c.3.2.5.5.7.9s.3.8.3,1.4,0,1-.3,1.4c-.2.4-.4.7-.7.9-.3.2-.7.3-1,.3s-.5,0-.7-.1c-.2,0-.3-.2-.5-.3-.1-.1-.2-.3-.3-.4h0v.8h-1.1,0ZM49.5,24.6c0,.3,0,.6.1.8,0,.2.2.4.4.6.2.1.4.2.6.2s.5,0,.6-.2.3-.3.4-.6.1-.5.1-.8,0-.6-.1-.8-.2-.4-.4-.6c-.2-.1-.4-.2-.7-.2s-.5,0-.6.2c-.2.1-.3.3-.4.5s-.1.5-.1.8h0Z"/>
+    <path class="st0" d="M54.5,28.9c-.2,0-.3,0-.4,0-.1,0-.2,0-.3,0l.3-.9c.2,0,.3,0,.5,0s.2,0,.3-.1.2-.2.3-.4v-.3c0,0-1.7-5-1.7-5h1.2l1.1,3.7h0l1.1-3.7h1.2l-2,5.5c0,.3-.2.5-.4.7s-.3.3-.5.4c-.2.1-.5.1-.8.1h0Z"/>
+  </g>

internal/cmd/stats/header.svg 🔗

@@ -0,0 +1,673 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" version="1.1" viewBox="0 0 829.5 43">
+  <!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136)  -->
+  <defs>
+    <style>
+      .st0 {
+        fill: url(#linear-gradient) !important;
+      }
+    </style>
+    <linearGradient id="linear-gradient" x1="0" y1="22.5" x2="829.5" y2="22.5" gradientTransform="translate(0 44) scale(1 -1)" gradientUnits="userSpaceOnUse">
+      <stop offset="0" stop-color="#6b50ff"/>
+      <stop offset="1" stop-color="#ff6daa"/>
+    </linearGradient>
+  </defs>
+  <g id="Header_WIP_copy">

internal/cmd/stats/heartbit.svg 🔗

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50.47 42.99">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #ff6daa;
+      }
+
+      .cls-2 {
+        fill: #ff13a9;
+      }
+
+      .cls-3 {
+        fill: #ff388b;
+      }
+    </style>
+  </defs>
+  <g id="Header_HeartBit_04" data-name="Header HeartBit 04">
+    <g>
+      <polygon id="Heart_Pink" data-name="Heart Pink" class="cls-1" points="22.87 7.13 22.87 3.48 20.3 3.48 20.3 0 7.59 0 7.59 3.48 3.66 3.48 3.66 7.13 0 7.13 0 25.24 3.66 25.24 3.66 30.73 7.59 30.73 7.59 35.49 12.99 35.49 12.99 38.05 17.38 38.05 17.38 40.24 20.85 40.24 20.85 42.99 29.62 42.99 29.62 40.24 33.09 40.24 33.09 38.05 37.48 38.05 37.48 35.49 42.88 35.49 42.88 30.73 46.81 30.73 46.81 25.24 50.47 25.24 50.47 7.13 46.81 7.13 46.81 3.48 42.88 3.48 42.88 0 30.17 0 30.17 3.48 27.6 3.48 27.6 7.13 22.87 7.13"/>
+      <g>
+        <rect id="Face_02" data-name="Face 02" x="30.32" y="16.07" width="2.16" height="5.85"/>
+        <path d="M32.53,21.98h-2.27v-5.96h2.27v5.96Z"/>
+      </g>
+      <g>
+        <rect id="Face_02-2" data-name="Face 02" x="17.99" y="16.07" width="2.16" height="5.85"/>
+        <path d="M20.21,21.98h-2.27v-5.96h2.27v5.96Z"/>
+      </g>
+      <g>
+        <rect id="Face_02-3" data-name="Face 02" class="cls-2" x="14.44" y="22.11" width="2.16" height="2.16"/>
+        <path class="cls-3" d="M14.5,24.21h2.05v-2.05h-2.05v2.05Z"/>
+      </g>
+      <g>
+        <rect id="Face_02-4" data-name="Face 02" class="cls-2" x="33.87" y="22.11" width="2.16" height="2.16"/>
+        <path class="cls-3" d="M36.08,24.32h-2.27v-2.27h2.27v2.27Z"/>
+      </g>
+      <g>
+        <rect id="Face_02-5" data-name="Face 02" x="23.43" y="22.11" width="3.62" height="2.16"/>
+        <path d="M27.1,24.32h-3.73v-2.27h3.73v2.27Z"/>
+      </g>
+    </g>
+  </g>
+</svg>

internal/cmd/stats/index.css 🔗

@@ -0,0 +1,275 @@
+:root {
+  /* Dark mode colors - charmtone dark palette */
+  --bg: #201f26;
+  --bg-secondary: #2d2c35;
+  --text: #fffaf1;
+  --text-muted: #858392;
+
+  /* Charmtone colors (global - same in both light and dark modes) */
+  --charple: #6b50ff;
+  --cherry: #ff388b;
+  --julep: #00ffb2;
+  --urchin: #c337e0;
+  --butter: #fffaf1;
+  --squid: #858392;
+  --pepper: #201f26;
+  --iron: #4d4c57;
+  --tuna: #ff6daa;
+  --uni: #ff937d;
+  --coral: #ff577d;
+  --violet: #c259ff;
+  --malibu: #00a4ff;
+  --hazy: #8b75ff;
+}
+
+/* Light mode colors - charmtone light palette */
+@media (prefers-color-scheme: light) {
+  :root {
+    --bg: #f0f0f0;
+    --bg-secondary: #fbfbfb;
+    --text: #201f26;
+    --text-muted: #4d4c57;
+  }
+}
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family:
+    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
+    sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  line-height: 1.6;
+  padding: 2rem 1rem;
+}
+
+.container {
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.header-wrapper {
+  max-width: 1200px;
+  margin: 0 auto 2rem;
+}
+
+.header-wrapper a {
+  display: block;
+  text-decoration: none;
+}
+
+.header-content {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+.header-svg {
+  flex-grow: 1;
+  flex-shrink: 1;
+  min-width: 0;
+  overflow: hidden;
+  height: 70px;
+  display: flex;
+  align-items: center;
+}
+
+.header-svg svg {
+  height: 70px;
+  width: auto;
+  min-width: 1300px;
+  display: block;
+  pointer-events: none;
+}
+
+.heartbit-svg {
+  flex-shrink: 0;
+  width: 70px;
+  flex-basis: 70px;
+  margin-left: 1rem;
+}
+
+.heartbit-svg svg {
+  width: 100%;
+  height: auto;
+  display: block;
+}
+
+.header-info {
+  margin-bottom: 2rem;
+  font-size: 0.875rem;
+  color: var(--hazy);
+  font-family: "JetBrains Mono", "SF Mono", Consolas, monospace;
+}
+
+.stats-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 1rem;
+  margin-bottom: 2rem;
+  width: 100%;
+}
+
+.stat-card {
+  background: var(--bg-secondary);
+  border-radius: 12px;
+  padding: 1.5rem;
+  flex: 1 1 150px;
+  max-width: calc((100% - 5rem) / 6);
+}
+
+@media (prefers-color-scheme: light) {
+  .stat-card {
+    background: var(--butter);
+  }
+}
+
+@media (max-width: 1024px) {
+  .stat-card {
+    max-width: calc((100% - 2rem) / 3);
+  }
+}
+
+@media (max-width: 600px) {
+  .stat-card {
+    max-width: calc((100% - 1rem) / 2);
+  }
+}
+
+.stat-card h3 {
+  font-size: 0.75rem;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin-bottom: 0.5rem;
+}
+
+.stat-card .value {
+  font-size: 2rem;
+  font-weight: 700;
+  color: var(--butter);
+  white-space: nowrap;
+}
+
+@media (prefers-color-scheme: light) {
+  .stat-card .value {
+    color: var(--pepper);
+  }
+}
+
+.charts-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+  margin-bottom: 2rem;
+  width: 100%;
+}
+
+.chart-card {
+  background: var(--bg-secondary);
+  border-radius: 12px;
+  padding: 1.5rem;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+@media (prefers-color-scheme: light) {
+  .chart-card {
+    background: var(--butter);
+  }
+}
+
+.chart-card.full-width {
+  width: 100%;
+}
+
+.chart-row {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 1.5rem;
+  width: 100%;
+}
+
+.chart-row .chart-card {
+  width: 100%;
+}
+
+@media (max-width: 1024px) {
+  .chart-row {
+    grid-template-columns: 1fr;
+  }
+}
+
+.chart-card h2 {
+  font-size: 1.25rem;
+  margin-bottom: 1rem;
+  color: var(--text);
+}
+
+.chart-container {
+  position: relative;
+  height: 300px;
+}
+
+.chart-container.tall {
+  height: 400px;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  margin-top: 1rem;
+}
+
+th,
+td {
+  text-align: left;
+  padding: 0.75rem;
+  border-bottom: 1px solid var(--border);
+}
+
+th {
+  color: var(--text-muted);
+  font-weight: 500;
+  font-size: 0.875rem;
+}
+
+td {
+  font-family: "JetBrains Mono", "SF Mono", Consolas, monospace;
+}
+
+.model-tag {
+  background: var(--bg);
+  padding: 0.25rem 0.5rem;
+  border-radius: 4px;
+  font-size: 0.875rem;
+}
+
+.footer-container {
+  max-width: 1200px;
+  margin: 2rem auto 0;
+}
+
+.footer-container svg {
+  width: 100%;
+  height: auto;
+  display: block;
+}
+
+/* Override charm brand colors in footer */
+.footer-container .st2 {
+  fill: #fffaf1 !important;
+}
+
+@media (prefers-color-scheme: light) {
+
+  /* Override charm brand colors in footer */
+  .footer-container .st2 {
+    fill: #644ced !important;
+  }
+}

internal/cmd/stats/index.html 🔗

@@ -0,0 +1,136 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Crush Usage Statistics</title>
+    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link
+      href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
+      rel="stylesheet"
+    />
+    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+    <style>
+      {{.CSS}}
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="header-wrapper">
+        <a href="https://charm.land/crush" class="header-link">
+          <div class="header-content">
+            <div class="header-svg">{{.Header}}</div>
+            <div class="heartbit-svg">{{.Heartbit}}</div>
+          </div>
+        </a>
+      </div>
+
+      <div class="header-info">
+        Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}.
+      </div>
+
+      <div class="stats-grid">
+        <div class="stat-card">
+          <h3>Total Sessions</h3>
+          <div class="value" id="total-sessions"></div>
+        </div>
+        <div class="stat-card">
+          <h3>Total Messages</h3>
+          <div class="value" id="total-messages"></div>
+        </div>
+        <div class="stat-card">
+          <h3>Total Tokens</h3>
+          <div class="value" id="total-tokens"></div>
+        </div>
+        <div class="stat-card">
+          <h3>Total Cost</h3>
+          <div class="value cost" id="total-cost"></div>
+        </div>
+        <div class="stat-card">
+          <h3>Tokens/Session</h3>
+          <div class="value" id="avg-tokens"></div>
+        </div>
+        <div class="stat-card">
+          <h3>Response Time</h3>
+          <div class="value" id="avg-response"></div>
+        </div>
+      </div>
+
+      <div class="charts-grid">
+        <div class="chart-card full-width">
+          <h2>Activity Heatmap</h2>
+          <div class="chart-container tall">
+            <canvas id="heatmapChart"></canvas>
+          </div>
+        </div>
+
+        <div class="chart-card full-width">
+          <h2>Activity (Last 30 Days)</h2>
+          <div class="chart-container tall">
+            <canvas id="recentActivityChart"></canvas>
+          </div>
+        </div>
+
+        <div class="chart-card full-width">
+          <h2>Tool Usage</h2>
+          <div class="chart-container tall">
+            <canvas id="toolChart"></canvas>
+          </div>
+        </div>
+
+        <div class="chart-row">
+          <div class="chart-card">
+            <h2>Messages by Provider</h2>
+            <div class="chart-container">
+              <canvas id="providerPieChart"></canvas>
+            </div>
+          </div>
+
+          <div class="chart-card">
+            <h2>Token Distribution</h2>
+            <div class="chart-container">
+              <canvas id="tokenPieChart"></canvas>
+            </div>
+          </div>
+        </div>
+
+        <div class="chart-card full-width">
+          <h2>Usage by Model</h2>
+          <div class="chart-container tall">
+            <canvas id="modelChart"></canvas>
+          </div>
+        </div>
+
+        <div class="chart-card full-width">
+          <h2>Daily Usage History</h2>
+          <div style="overflow-x: auto">
+            <table id="daily-table">
+              <thead>
+                <tr>
+                  <th>Date</th>
+                  <th>Sessions</th>
+                  <th>Prompt Tokens</th>
+                  <th>Completion Tokens</th>
+                  <th>Total Tokens</th>
+                  <th>Cost</th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="footer-container">
+      <div class="footer">{{.Footer}}</div>
+    </div>
+
+    <script>
+      const stats = {{.StatsJSON}};
+      {{.JS}}
+    </script>
+  </body>
+</html>

internal/cmd/stats/index.js 🔗

@@ -0,0 +1,356 @@
+// Get all charmtone colors once from computed styles
+const rootStyles = getComputedStyle(document.documentElement);
+const colors = {
+  charple: rootStyles.getPropertyValue("--charple").trim(),
+  cherry: rootStyles.getPropertyValue("--cherry").trim(),
+  julep: rootStyles.getPropertyValue("--julep").trim(),
+  urchin: rootStyles.getPropertyValue("--urchin").trim(),
+  butter: rootStyles.getPropertyValue("--butter").trim(),
+  squid: rootStyles.getPropertyValue("--squid").trim(),
+  pepper: rootStyles.getPropertyValue("--pepper").trim(),
+  tuna: rootStyles.getPropertyValue("--tuna").trim(),
+  uni: rootStyles.getPropertyValue("--uni").trim(),
+  coral: rootStyles.getPropertyValue("--coral").trim(),
+  violet: rootStyles.getPropertyValue("--violet").trim(),
+  malibu: rootStyles.getPropertyValue("--malibu").trim(),
+};
+
+const easeDuration = 500;
+const easeType = "easeOutQuart";
+
+// Helper functions
+function formatNumber(n) {
+  return new Intl.NumberFormat().format(Math.round(n));
+}
+
+function formatCompact(n) {
+  if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
+  if (n >= 1000) return (n / 1000).toFixed(1) + "k";
+  return Math.round(n).toString();
+}
+
+function formatCost(n) {
+  return "$" + n.toFixed(2);
+}
+
+function formatTime(ms) {
+  if (ms < 1000) return Math.round(ms) + "ms";
+  return (ms / 1000).toFixed(1) + "s";
+}
+
+const charpleColor = { r: 107, g: 80, b: 255 };
+const tunaColor = { r: 255, g: 109, b: 170 };
+
+function interpolateColor(ratio, alpha = 1) {
+  const r = Math.round(charpleColor.r + (tunaColor.r - charpleColor.r) * ratio);
+  const g = Math.round(charpleColor.g + (tunaColor.g - charpleColor.g) * ratio);
+  const b = Math.round(charpleColor.b + (tunaColor.b - charpleColor.b) * ratio);
+  if (alpha < 1) {
+    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+  }
+  return `rgb(${r}, ${g}, ${b})`;
+}
+
+function getTopItemsWithOthers(items, countKey, labelKey, topN = 10) {
+  const topItems = items.slice(0, topN);
+  const otherItems = items.slice(topN);
+  const otherCount = otherItems.reduce((sum, item) => sum + item[countKey], 0);
+  const displayItems = [...topItems];
+  if (otherItems.length > 0) {
+    const otherItem = { [countKey]: otherCount, [labelKey]: "others" };
+    displayItems.push(otherItem);
+  }
+  return displayItems;
+}
+
+// Populate summary cards
+document.getElementById("total-sessions").textContent = formatNumber(
+  stats.total.total_sessions,
+);
+document.getElementById("total-messages").textContent = formatCompact(
+  stats.total.total_messages,
+);
+document.getElementById("total-tokens").textContent = formatCompact(
+  stats.total.total_tokens,
+);
+document.getElementById("total-cost").textContent = formatCost(
+  stats.total.total_cost,
+);
+document.getElementById("avg-tokens").innerHTML =
+  '<span title="Average">x̅</span> ' +
+  formatCompact(stats.total.avg_tokens_per_session);
+document.getElementById("avg-response").innerHTML =
+  '<span title="Average">x̅</span> ' + formatTime(stats.avg_response_time_ms);
+
+// Chart defaults
+Chart.defaults.color = colors.squid;
+Chart.defaults.borderColor = colors.squid;
+
+if (stats.recent_activity?.length > 0) {
+  new Chart(document.getElementById("recentActivityChart"), {
+    type: "bar",
+    data: {
+      labels: stats.recent_activity.map((d) => d.day),
+      datasets: [
+        {
+          label: "Sessions",
+          data: stats.recent_activity.map((d) => d.session_count),
+          backgroundColor: colors.charple,
+          borderRadius: 4,
+          yAxisID: "y",
+        },
+        {
+          label: "Tokens (K)",
+          data: stats.recent_activity.map((d) => d.total_tokens / 1000),
+          backgroundColor: colors.julep,
+          borderRadius: 4,
+          yAxisID: "y1",
+        },
+      ],
+    },
+    options: {
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: { duration: 800, easing: easeType },
+      interaction: { mode: "index", intersect: false },
+      scales: {
+        y: { position: "left", title: { display: true, text: "Sessions" } },
+        y1: {
+          position: "right",
+          title: { display: true, text: "Tokens (K)" },
+          grid: { drawOnChartArea: false },
+        },
+      },
+    },
+  });
+}
+
+// Heatmap (Hour × Day of Week) - Bubble Chart
+const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+let maxCount =
+  stats.hour_day_heatmap?.length > 0
+    ? Math.max(...stats.hour_day_heatmap.map((h) => h.session_count))
+    : 0;
+if (maxCount === 0) maxCount = 1;
+const scaleFactor = 20 / Math.sqrt(maxCount);
+
+if (stats.hour_day_heatmap?.length > 0) {
+  new Chart(document.getElementById("heatmapChart"), {
+    type: "bubble",
+    data: {
+      datasets: [
+        {
+          label: "Sessions",
+          data: stats.hour_day_heatmap
+            .filter((h) => h.session_count > 0)
+            .map((h) => ({
+              x: h.hour,
+              y: h.day_of_week,
+              r: Math.sqrt(h.session_count) * scaleFactor,
+              count: h.session_count,
+            })),
+          backgroundColor: (ctx) => {
+            const count =
+              ctx.raw?.count || ctx.dataset.data[ctx.dataIndex]?.count || 0;
+            const ratio = count / maxCount;
+            return interpolateColor(ratio);
+          },
+          borderWidth: 0,
+        },
+      ],
+    },
+    options: {
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: false,
+      scales: {
+        x: {
+          min: 0,
+          max: 23,
+          grid: { display: false },
+          title: { display: true, text: "Hour of Day" },
+          ticks: {
+            stepSize: 1,
+            callback: (v) => (Number.isInteger(v) ? v : ""),
+          },
+        },
+        y: {
+          min: 0,
+          max: 6,
+          reverse: true,
+          grid: { display: false },
+          title: { display: true, text: "Day of Week" },
+          ticks: { stepSize: 1, callback: (v) => dayLabels[v] || "" },
+        },
+      },
+      plugins: {
+        legend: { display: false },
+        tooltip: {
+          callbacks: {
+            label: (ctx) =>
+              dayLabels[ctx.raw.y] +
+              " " +
+              ctx.raw.x +
+              ":00 - " +
+              ctx.raw.count +
+              " sessions",
+          },
+        },
+      },
+    },
+  });
+}
+
+if (stats.tool_usage?.length > 0) {
+  const displayTools = getTopItemsWithOthers(
+    stats.tool_usage,
+    "call_count",
+    "tool_name",
+  );
+  const maxValue = Math.max(...displayTools.map((t) => t.call_count));
+  new Chart(document.getElementById("toolChart"), {
+    type: "bar",
+    data: {
+      labels: displayTools.map((t) => t.tool_name),
+      datasets: [
+        {
+          label: "Calls",
+          data: displayTools.map((t) => t.call_count),
+          backgroundColor: (ctx) => {
+            const value = ctx.raw;
+            const ratio = value / maxValue;
+            return interpolateColor(ratio);
+          },
+          borderRadius: 4,
+        },
+      ],
+    },
+    options: {
+      indexAxis: "y",
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: { duration: easeDuration, easing: easeType },
+      plugins: { legend: { display: false } },
+    },
+  });
+}
+
+// Token Distribution Pie
+new Chart(document.getElementById("tokenPieChart"), {
+  type: "doughnut",
+  data: {
+    labels: ["Prompt Tokens", "Completion Tokens"],
+    datasets: [
+      {
+        data: [
+          stats.total.total_prompt_tokens,
+          stats.total.total_completion_tokens,
+        ],
+        backgroundColor: [colors.charple, colors.julep],
+        borderWidth: 0,
+      },
+    ],
+  },
+  options: {
+    responsive: true,
+    maintainAspectRatio: false,
+    animation: { duration: easeDuration, easing: easeType },
+    plugins: {
+      legend: { position: "bottom" },
+    },
+  },
+});
+
+// Model Usage Chart (horizontal bar)
+if (stats.usage_by_model?.length > 0) {
+  const displayModels = getTopItemsWithOthers(
+    stats.usage_by_model,
+    "message_count",
+    "model",
+  );
+  const maxModelValue = Math.max(...displayModels.map((m) => m.message_count));
+  new Chart(document.getElementById("modelChart"), {
+    type: "bar",
+    data: {
+      labels: displayModels.map((m) =>
+        m.provider ? `${m.model} (${m.provider})` : m.model,
+      ),
+      datasets: [
+        {
+          label: "Messages",
+          data: displayModels.map((m) => m.message_count),
+          backgroundColor: (ctx) => {
+            const value = ctx.raw;
+            const ratio = value / maxModelValue;
+            return interpolateColor(ratio);
+          },
+          borderRadius: 4,
+        },
+      ],
+    },
+    options: {
+      indexAxis: "y",
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: { duration: easeDuration, easing: easeType },
+      plugins: { legend: { display: false } },
+    },
+  });
+}
+
+if (stats.usage_by_model?.length > 0) {
+  const providerData = stats.usage_by_model.reduce((acc, m) => {
+    acc[m.provider] = (acc[m.provider] || 0) + m.message_count;
+    return acc;
+  }, {});
+  const providerColors = [
+    colors.malibu,
+    colors.charple,
+    colors.violet,
+    colors.tuna,
+    colors.coral,
+    colors.uni,
+  ];
+  new Chart(document.getElementById("providerPieChart"), {
+    type: "doughnut",
+    data: {
+      labels: Object.keys(providerData),
+      datasets: [
+        {
+          data: Object.values(providerData),
+          backgroundColor: Object.keys(providerData).map(
+            (_, i) => providerColors[i % providerColors.length],
+          ),
+          borderWidth: 0,
+        },
+      ],
+    },
+    options: {
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: { duration: easeDuration, easing: easeType },
+      plugins: {
+        legend: { position: "bottom" },
+      },
+    },
+  });
+}
+
+// Daily Usage Table
+const tableBody = document.querySelector("#daily-table tbody");
+if (stats.usage_by_day?.length > 0) {
+  const fragment = document.createDocumentFragment();
+  stats.usage_by_day.slice(0, 30).forEach((d) => {
+    const row = document.createElement("tr");
+    row.innerHTML = `<td>${d.day}</td><td>${d.session_count}</td><td>${formatNumber(
+      d.prompt_tokens,
+    )}</td><td>${formatNumber(
+      d.completion_tokens,
+    )}</td><td>${formatNumber(d.total_tokens)}</td><td>${formatCost(
+      d.cost,
+    )}</td>`;
+    fragment.appendChild(row);
+  });
+  tableBody.appendChild(fragment);
+}

internal/db/db.go 🔗

@@ -48,18 +48,45 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
 		return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
 	}
+	if q.getAverageResponseTimeStmt, err = db.PrepareContext(ctx, getAverageResponseTime); err != nil {
+		return nil, fmt.Errorf("error preparing query GetAverageResponseTime: %w", err)
+	}
 	if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil {
 		return nil, fmt.Errorf("error preparing query GetFile: %w", err)
 	}
 	if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
 		return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
 	}
+	if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
+		return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
+	}
 	if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
 		return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
 	}
+	if q.getRecentActivityStmt, err = db.PrepareContext(ctx, getRecentActivity); err != nil {
+		return nil, fmt.Errorf("error preparing query GetRecentActivity: %w", err)
+	}
 	if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
 		return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
 	}
+	if q.getToolUsageStmt, err = db.PrepareContext(ctx, getToolUsage); err != nil {
+		return nil, fmt.Errorf("error preparing query GetToolUsage: %w", err)
+	}
+	if q.getTotalStatsStmt, err = db.PrepareContext(ctx, getTotalStats); err != nil {
+		return nil, fmt.Errorf("error preparing query GetTotalStats: %w", err)
+	}
+	if q.getUsageByDayStmt, err = db.PrepareContext(ctx, getUsageByDay); err != nil {
+		return nil, fmt.Errorf("error preparing query GetUsageByDay: %w", err)
+	}
+	if q.getUsageByDayOfWeekStmt, err = db.PrepareContext(ctx, getUsageByDayOfWeek); err != nil {
+		return nil, fmt.Errorf("error preparing query GetUsageByDayOfWeek: %w", err)
+	}
+	if q.getUsageByHourStmt, err = db.PrepareContext(ctx, getUsageByHour); err != nil {
+		return nil, fmt.Errorf("error preparing query GetUsageByHour: %w", err)
+	}
+	if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
+		return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
+	}
 	if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
 		return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
 	}
@@ -132,6 +159,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
 		}
 	}
+	if q.getAverageResponseTimeStmt != nil {
+		if cerr := q.getAverageResponseTimeStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getAverageResponseTimeStmt: %w", cerr)
+		}
+	}
 	if q.getFileStmt != nil {
 		if cerr := q.getFileStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing getFileStmt: %w", cerr)
@@ -142,16 +174,56 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
 		}
 	}
+	if q.getHourDayHeatmapStmt != nil {
+		if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
+		}
+	}
 	if q.getMessageStmt != nil {
 		if cerr := q.getMessageStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
 		}
 	}
+	if q.getRecentActivityStmt != nil {
+		if cerr := q.getRecentActivityStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getRecentActivityStmt: %w", cerr)
+		}
+	}
 	if q.getSessionByIDStmt != nil {
 		if cerr := q.getSessionByIDStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
 		}
 	}
+	if q.getToolUsageStmt != nil {
+		if cerr := q.getToolUsageStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getToolUsageStmt: %w", cerr)
+		}
+	}
+	if q.getTotalStatsStmt != nil {
+		if cerr := q.getTotalStatsStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getTotalStatsStmt: %w", cerr)
+		}
+	}
+	if q.getUsageByDayStmt != nil {
+		if cerr := q.getUsageByDayStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getUsageByDayStmt: %w", cerr)
+		}
+	}
+	if q.getUsageByDayOfWeekStmt != nil {
+		if cerr := q.getUsageByDayOfWeekStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getUsageByDayOfWeekStmt: %w", cerr)
+		}
+	}
+	if q.getUsageByHourStmt != nil {
+		if cerr := q.getUsageByHourStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getUsageByHourStmt: %w", cerr)
+		}
+	}
+	if q.getUsageByModelStmt != nil {
+		if cerr := q.getUsageByModelStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
+		}
+	}
 	if q.listFilesByPathStmt != nil {
 		if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -244,10 +316,19 @@ type Queries struct {
 	deleteSessionStmt              *sql.Stmt
 	deleteSessionFilesStmt         *sql.Stmt
 	deleteSessionMessagesStmt      *sql.Stmt
+	getAverageResponseTimeStmt     *sql.Stmt
 	getFileStmt                    *sql.Stmt
 	getFileByPathAndSessionStmt    *sql.Stmt
+	getHourDayHeatmapStmt          *sql.Stmt
 	getMessageStmt                 *sql.Stmt
+	getRecentActivityStmt          *sql.Stmt
 	getSessionByIDStmt             *sql.Stmt
+	getToolUsageStmt               *sql.Stmt
+	getTotalStatsStmt              *sql.Stmt
+	getUsageByDayStmt              *sql.Stmt
+	getUsageByDayOfWeekStmt        *sql.Stmt
+	getUsageByHourStmt             *sql.Stmt
+	getUsageByModelStmt            *sql.Stmt
 	listFilesByPathStmt            *sql.Stmt
 	listFilesBySessionStmt         *sql.Stmt
 	listLatestSessionFilesStmt     *sql.Stmt
@@ -271,10 +352,19 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		deleteSessionStmt:              q.deleteSessionStmt,
 		deleteSessionFilesStmt:         q.deleteSessionFilesStmt,
 		deleteSessionMessagesStmt:      q.deleteSessionMessagesStmt,
+		getAverageResponseTimeStmt:     q.getAverageResponseTimeStmt,
 		getFileStmt:                    q.getFileStmt,
 		getFileByPathAndSessionStmt:    q.getFileByPathAndSessionStmt,
+		getHourDayHeatmapStmt:          q.getHourDayHeatmapStmt,
 		getMessageStmt:                 q.getMessageStmt,
+		getRecentActivityStmt:          q.getRecentActivityStmt,
 		getSessionByIDStmt:             q.getSessionByIDStmt,
+		getToolUsageStmt:               q.getToolUsageStmt,
+		getTotalStatsStmt:              q.getTotalStatsStmt,
+		getUsageByDayStmt:              q.getUsageByDayStmt,
+		getUsageByDayOfWeekStmt:        q.getUsageByDayOfWeekStmt,
+		getUsageByHourStmt:             q.getUsageByHourStmt,
+		getUsageByModelStmt:            q.getUsageByModelStmt,
 		listFilesByPathStmt:            q.listFilesByPathStmt,
 		listFilesBySessionStmt:         q.listFilesBySessionStmt,
 		listLatestSessionFilesStmt:     q.listLatestSessionFilesStmt,

internal/db/querier.go 🔗

@@ -17,10 +17,19 @@ type Querier interface {
 	DeleteSession(ctx context.Context, id string) error
 	DeleteSessionFiles(ctx context.Context, sessionID string) error
 	DeleteSessionMessages(ctx context.Context, sessionID string) error
+	GetAverageResponseTime(ctx context.Context) (int64, error)
 	GetFile(ctx context.Context, id string) (File, error)
 	GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
+	GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
 	GetMessage(ctx context.Context, id string) (Message, error)
+	GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
 	GetSessionByID(ctx context.Context, id string) (Session, error)
+	GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error)
+	GetTotalStats(ctx context.Context) (GetTotalStatsRow, error)
+	GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error)
+	GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
+	GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
+	GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
 	ListFilesByPath(ctx context.Context, path string) ([]File, error)
 	ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
 	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)

internal/db/sql/stats.sql 🔗

@@ -0,0 +1,93 @@
+-- name: GetUsageByDay :many
+SELECT
+    date(created_at, 'unixepoch') as day,
+    SUM(prompt_tokens) as prompt_tokens,
+    SUM(completion_tokens) as completion_tokens,
+    SUM(cost) as cost,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY date(created_at, 'unixepoch')
+ORDER BY day DESC;
+
+-- name: GetUsageByModel :many
+SELECT
+    COALESCE(model, 'unknown') as model,
+    COALESCE(provider, 'unknown') as provider,
+    COUNT(*) as message_count
+FROM messages
+WHERE role = 'assistant'
+GROUP BY model, provider
+ORDER BY message_count DESC;
+
+-- name: GetUsageByHour :many
+SELECT
+    CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY hour
+ORDER BY hour;
+
+-- name: GetUsageByDayOfWeek :many
+SELECT
+    CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week,
+    COUNT(*) as session_count,
+    SUM(prompt_tokens) as prompt_tokens,
+    SUM(completion_tokens) as completion_tokens
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY day_of_week
+ORDER BY day_of_week;
+
+-- name: GetTotalStats :one
+SELECT
+    COUNT(*) as total_sessions,
+    COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens,
+    COALESCE(SUM(completion_tokens), 0) as total_completion_tokens,
+    COALESCE(SUM(cost), 0) as total_cost,
+    COALESCE(SUM(message_count), 0) as total_messages,
+    COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session,
+    COALESCE(AVG(message_count), 0) as avg_messages_per_session
+FROM sessions
+WHERE parent_session_id IS NULL;
+
+-- name: GetRecentActivity :many
+SELECT
+    date(created_at, 'unixepoch') as day,
+    COUNT(*) as session_count,
+    SUM(prompt_tokens + completion_tokens) as total_tokens,
+    SUM(cost) as cost
+FROM sessions
+WHERE parent_session_id IS NULL
+  AND created_at >= strftime('%s', 'now', '-30 days')
+GROUP BY date(created_at, 'unixepoch')
+ORDER BY day ASC;
+
+-- name: GetAverageResponseTime :one
+SELECT
+    CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds
+FROM messages
+WHERE role = 'assistant'
+  AND finished_at IS NOT NULL
+  AND finished_at > created_at;
+
+-- name: GetToolUsage :many
+SELECT
+    json_extract(value, '$.data.name') as tool_name,
+    COUNT(*) as call_count
+FROM messages, json_each(parts)
+WHERE json_extract(value, '$.type') = 'tool_call'
+  AND json_extract(value, '$.data.name') IS NOT NULL
+GROUP BY tool_name
+ORDER BY call_count DESC;
+
+-- name: GetHourDayHeatmap :many
+SELECT
+    CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week,
+    CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY day_of_week, hour
+ORDER BY day_of_week, hour;

internal/db/stats.sql.go 🔗

@@ -0,0 +1,367 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: stats.sql
+
+package db
+
+import (
+	"context"
+	"database/sql"
+)
+
+const getAverageResponseTime = `-- name: GetAverageResponseTime :one
+SELECT
+    CAST(COALESCE(AVG(finished_at - created_at), 0) AS INTEGER) as avg_response_seconds
+FROM messages
+WHERE role = 'assistant'
+  AND finished_at IS NOT NULL
+  AND finished_at > created_at
+`
+
+func (q *Queries) GetAverageResponseTime(ctx context.Context) (int64, error) {
+	row := q.queryRow(ctx, q.getAverageResponseTimeStmt, getAverageResponseTime)
+	var avg_response_seconds int64
+	err := row.Scan(&avg_response_seconds)
+	return avg_response_seconds, err
+}
+
+const getHourDayHeatmap = `-- name: GetHourDayHeatmap :many
+SELECT
+    CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week,
+    CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY day_of_week, hour
+ORDER BY day_of_week, hour
+`
+
+type GetHourDayHeatmapRow struct {
+	DayOfWeek    int64 `json:"day_of_week"`
+	Hour         int64 `json:"hour"`
+	SessionCount int64 `json:"session_count"`
+}
+
+func (q *Queries) GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) {
+	rows, err := q.query(ctx, q.getHourDayHeatmapStmt, getHourDayHeatmap)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetHourDayHeatmapRow{}
+	for rows.Next() {
+		var i GetHourDayHeatmapRow
+		if err := rows.Scan(&i.DayOfWeek, &i.Hour, &i.SessionCount); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getRecentActivity = `-- name: GetRecentActivity :many
+SELECT
+    date(created_at, 'unixepoch') as day,
+    COUNT(*) as session_count,
+    SUM(prompt_tokens + completion_tokens) as total_tokens,
+    SUM(cost) as cost
+FROM sessions
+WHERE parent_session_id IS NULL
+  AND created_at >= strftime('%s', 'now', '-30 days')
+GROUP BY date(created_at, 'unixepoch')
+ORDER BY day ASC
+`
+
+type GetRecentActivityRow struct {
+	Day          interface{}     `json:"day"`
+	SessionCount int64           `json:"session_count"`
+	TotalTokens  sql.NullFloat64 `json:"total_tokens"`
+	Cost         sql.NullFloat64 `json:"cost"`
+}
+
+func (q *Queries) GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) {
+	rows, err := q.query(ctx, q.getRecentActivityStmt, getRecentActivity)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetRecentActivityRow{}
+	for rows.Next() {
+		var i GetRecentActivityRow
+		if err := rows.Scan(
+			&i.Day,
+			&i.SessionCount,
+			&i.TotalTokens,
+			&i.Cost,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getToolUsage = `-- name: GetToolUsage :many
+SELECT
+    json_extract(value, '$.data.name') as tool_name,
+    COUNT(*) as call_count
+FROM messages, json_each(parts)
+WHERE json_extract(value, '$.type') = 'tool_call'
+  AND json_extract(value, '$.data.name') IS NOT NULL
+GROUP BY tool_name
+ORDER BY call_count DESC
+`
+
+type GetToolUsageRow struct {
+	ToolName  interface{} `json:"tool_name"`
+	CallCount int64       `json:"call_count"`
+}
+
+func (q *Queries) GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) {
+	rows, err := q.query(ctx, q.getToolUsageStmt, getToolUsage)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetToolUsageRow{}
+	for rows.Next() {
+		var i GetToolUsageRow
+		if err := rows.Scan(&i.ToolName, &i.CallCount); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getTotalStats = `-- name: GetTotalStats :one
+SELECT
+    COUNT(*) as total_sessions,
+    COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens,
+    COALESCE(SUM(completion_tokens), 0) as total_completion_tokens,
+    COALESCE(SUM(cost), 0) as total_cost,
+    COALESCE(SUM(message_count), 0) as total_messages,
+    COALESCE(AVG(prompt_tokens + completion_tokens), 0) as avg_tokens_per_session,
+    COALESCE(AVG(message_count), 0) as avg_messages_per_session
+FROM sessions
+WHERE parent_session_id IS NULL
+`
+
+type GetTotalStatsRow struct {
+	TotalSessions         int64       `json:"total_sessions"`
+	TotalPromptTokens     interface{} `json:"total_prompt_tokens"`
+	TotalCompletionTokens interface{} `json:"total_completion_tokens"`
+	TotalCost             interface{} `json:"total_cost"`
+	TotalMessages         interface{} `json:"total_messages"`
+	AvgTokensPerSession   interface{} `json:"avg_tokens_per_session"`
+	AvgMessagesPerSession interface{} `json:"avg_messages_per_session"`
+}
+
+func (q *Queries) GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) {
+	row := q.queryRow(ctx, q.getTotalStatsStmt, getTotalStats)
+	var i GetTotalStatsRow
+	err := row.Scan(
+		&i.TotalSessions,
+		&i.TotalPromptTokens,
+		&i.TotalCompletionTokens,
+		&i.TotalCost,
+		&i.TotalMessages,
+		&i.AvgTokensPerSession,
+		&i.AvgMessagesPerSession,
+	)
+	return i, err
+}
+
+const getUsageByDay = `-- name: GetUsageByDay :many
+SELECT
+    date(created_at, 'unixepoch') as day,
+    SUM(prompt_tokens) as prompt_tokens,
+    SUM(completion_tokens) as completion_tokens,
+    SUM(cost) as cost,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY date(created_at, 'unixepoch')
+ORDER BY day DESC
+`
+
+type GetUsageByDayRow struct {
+	Day              interface{}     `json:"day"`
+	PromptTokens     sql.NullFloat64 `json:"prompt_tokens"`
+	CompletionTokens sql.NullFloat64 `json:"completion_tokens"`
+	Cost             sql.NullFloat64 `json:"cost"`
+	SessionCount     int64           `json:"session_count"`
+}
+
+func (q *Queries) GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) {
+	rows, err := q.query(ctx, q.getUsageByDayStmt, getUsageByDay)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetUsageByDayRow{}
+	for rows.Next() {
+		var i GetUsageByDayRow
+		if err := rows.Scan(
+			&i.Day,
+			&i.PromptTokens,
+			&i.CompletionTokens,
+			&i.Cost,
+			&i.SessionCount,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getUsageByDayOfWeek = `-- name: GetUsageByDayOfWeek :many
+SELECT
+    CAST(strftime('%w', created_at, 'unixepoch') AS INTEGER) as day_of_week,
+    COUNT(*) as session_count,
+    SUM(prompt_tokens) as prompt_tokens,
+    SUM(completion_tokens) as completion_tokens
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY day_of_week
+ORDER BY day_of_week
+`
+
+type GetUsageByDayOfWeekRow struct {
+	DayOfWeek        int64           `json:"day_of_week"`
+	SessionCount     int64           `json:"session_count"`
+	PromptTokens     sql.NullFloat64 `json:"prompt_tokens"`
+	CompletionTokens sql.NullFloat64 `json:"completion_tokens"`
+}
+
+func (q *Queries) GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) {
+	rows, err := q.query(ctx, q.getUsageByDayOfWeekStmt, getUsageByDayOfWeek)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetUsageByDayOfWeekRow{}
+	for rows.Next() {
+		var i GetUsageByDayOfWeekRow
+		if err := rows.Scan(
+			&i.DayOfWeek,
+			&i.SessionCount,
+			&i.PromptTokens,
+			&i.CompletionTokens,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getUsageByHour = `-- name: GetUsageByHour :many
+SELECT
+    CAST(strftime('%H', created_at, 'unixepoch') AS INTEGER) as hour,
+    COUNT(*) as session_count
+FROM sessions
+WHERE parent_session_id IS NULL
+GROUP BY hour
+ORDER BY hour
+`
+
+type GetUsageByHourRow struct {
+	Hour         int64 `json:"hour"`
+	SessionCount int64 `json:"session_count"`
+}
+
+func (q *Queries) GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) {
+	rows, err := q.query(ctx, q.getUsageByHourStmt, getUsageByHour)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetUsageByHourRow{}
+	for rows.Next() {
+		var i GetUsageByHourRow
+		if err := rows.Scan(&i.Hour, &i.SessionCount); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const getUsageByModel = `-- name: GetUsageByModel :many
+SELECT
+    COALESCE(model, 'unknown') as model,
+    COALESCE(provider, 'unknown') as provider,
+    COUNT(*) as message_count
+FROM messages
+WHERE role = 'assistant'
+GROUP BY model, provider
+ORDER BY message_count DESC
+`
+
+type GetUsageByModelRow struct {
+	Model        string `json:"model"`
+	Provider     string `json:"provider"`
+	MessageCount int64  `json:"message_count"`
+}
+
+func (q *Queries) GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) {
+	rows, err := q.query(ctx, q.getUsageByModelStmt, getUsageByModel)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []GetUsageByModelRow{}
+	for rows.Next() {
+		var i GetUsageByModelRow
+		if err := rows.Scan(&i.Model, &i.Provider, &i.MessageCount); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}