Detailed changes
@@ -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:
@@ -51,6 +51,7 @@ func init() {
logsCmd,
schemaCmd,
loginCmd,
+ statsCmd,
)
}
@@ -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)
+}
@@ -0,0 +1,3 @@
+# General Guidelines
+
+- always format CSS, HTML, and JS files with `prettier`
@@ -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>
@@ -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">
@@ -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>
@@ -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;
+ }
+}
@@ -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>
@@ -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);
+}
@@ -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,
@@ -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)
@@ -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;
@@ -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
+}