diff --git a/Taskfile.yaml b/Taskfile.yaml
index b626c2fdd767d4da40b192de9e454fb8f2050afd..9ffe8923d6bbd92caf441d872726de48352b2faa 100644
--- a/Taskfile.yaml
+++ b/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:
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index c08bd839dda7db5fa00cf46cc7a2dde61924d819..e7489777b5938b0edecba2b333643839974fede5 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -51,6 +51,7 @@ func init() {
logsCmd,
schemaCmd,
loginCmd,
+ statsCmd,
)
}
diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go
new file mode 100644
index 0000000000000000000000000000000000000000..5dc971d1229350f35f93d5cf772239fa83e9206e
--- /dev/null
+++ b/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)
+}
diff --git a/internal/cmd/stats/AGENTS.md b/internal/cmd/stats/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..b2b557e2d074fc7940a79cd0c07b3685753a8ab1
--- /dev/null
+++ b/internal/cmd/stats/AGENTS.md
@@ -0,0 +1,3 @@
+# General Guidelines
+
+- always format CSS, HTML, and JS files with `prettier`
diff --git a/internal/cmd/stats/footer.svg b/internal/cmd/stats/footer.svg
new file mode 100644
index 0000000000000000000000000000000000000000..06b4c85ac3e672e531981a1d7e01aaee58552233
--- /dev/null
+++ b/internal/cmd/stats/footer.svg
@@ -0,0 +1,838 @@
+
+
\ No newline at end of file
diff --git a/internal/cmd/stats/header.svg b/internal/cmd/stats/header.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b97ba95adea196efa0e62202d8755e91f09c1f76
--- /dev/null
+++ b/internal/cmd/stats/header.svg
@@ -0,0 +1,673 @@
+
+
diff --git a/internal/cmd/stats/heartbit.svg b/internal/cmd/stats/heartbit.svg
new file mode 100644
index 0000000000000000000000000000000000000000..daef4b0b57f392d02699d5e7371671633ffd57f2
--- /dev/null
+++ b/internal/cmd/stats/heartbit.svg
@@ -0,0 +1,43 @@
+
+
\ No newline at end of file
diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..b01c84442f6cbe1675f46ec02a65d801d0abed2d
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..4b25831f86c76f86bf405d3c9e77ddb2b7d1821e
--- /dev/null
+++ b/internal/cmd/stats/index.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+ Crush Usage Statistics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Activity Heatmap
+
+
+
+
+
+
+
Activity (Last 30 Days)
+
+
+
+
+
+
+
+
+
+
Messages by Provider
+
+
+
+
+
+
+
Token Distribution
+
+
+
+
+
+
+
+
+
+
Daily Usage History
+
+
+
+
+ | Date |
+ Sessions |
+ Prompt Tokens |
+ Completion Tokens |
+ Total Tokens |
+ Cost |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/cmd/stats/index.js b/internal/cmd/stats/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3007322881e458312a31702f735c1c3257bcfb6d
--- /dev/null
+++ b/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 =
+ 'x̅ ' +
+ formatCompact(stats.total.avg_tokens_per_session);
+document.getElementById("avg-response").innerHTML =
+ 'x̅ ' + 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 = `${d.day} | ${d.session_count} | ${formatNumber(
+ d.prompt_tokens,
+ )} | ${formatNumber(
+ d.completion_tokens,
+ )} | ${formatNumber(d.total_tokens)} | ${formatCost(
+ d.cost,
+ )} | `;
+ fragment.appendChild(row);
+ });
+ tableBody.appendChild(fragment);
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 7fa2e6528743dcb5485c0de9b4a3f2b46eb39376..81c3179e22f6768b2ffa2c5b4af2e10c385d5835 100644
--- a/internal/db/db.go
+++ b/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,
diff --git a/internal/db/querier.go b/internal/db/querier.go
index dfa6d722535b4265f3f54331d1904523a648f562..c70386690c6c42aca53a2b6682ddca0f3a0262ba 100644
--- a/internal/db/querier.go
+++ b/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)
diff --git a/internal/db/sql/stats.sql b/internal/db/sql/stats.sql
new file mode 100644
index 0000000000000000000000000000000000000000..02f2c33425b299870827b2d05f458106b82b599c
--- /dev/null
+++ b/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;
diff --git a/internal/db/stats.sql.go b/internal/db/stats.sql.go
new file mode 100644
index 0000000000000000000000000000000000000000..119dd410a07c7e47a2f7369c0ee2fdca8c19b7c3
--- /dev/null
+++ b/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
+}