claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

// --- stats-cache.json types ---

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsCache {
    pub version: u32,
    #[serde(default)]
    pub last_computed_date: Option<String>,
    #[serde(default)]
    pub daily_activity: Vec<DailyActivity>,
    #[serde(default)]
    pub daily_model_tokens: Vec<DailyModelTokens>,
    #[serde(default)]
    pub model_usage: HashMap<String, ModelUsage>,
    #[serde(default)]
    pub total_sessions: u64,
    #[serde(default)]
    pub total_messages: u64,
    #[serde(default)]
    pub longest_session: Option<LongestSession>,
    #[serde(default)]
    pub first_session_date: Option<String>,
    #[serde(default)]
    pub hour_counts: HashMap<String, u64>,
    #[serde(default)]
    pub total_speculation_time_saved_ms: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyActivity {
    pub date: String,
    #[serde(default)]
    pub message_count: u64,
    #[serde(default)]
    pub session_count: u64,
    #[serde(default)]
    pub tool_call_count: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyModelTokens {
    pub date: String,
    #[serde(default)]
    pub tokens_by_model: HashMap<String, u64>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
    #[serde(default)]
    pub input_tokens: u64,
    #[serde(default)]
    pub output_tokens: u64,
    #[serde(default)]
    pub cache_read_input_tokens: u64,
    #[serde(default)]
    pub cache_creation_input_tokens: u64,
    #[serde(default)]
    pub web_search_requests: u64,
    #[serde(default)]
    pub cost_usd: f64,
    #[serde(default)]
    pub context_window: u64,
    #[serde(default)]
    pub max_output_tokens: u64,
}

impl ModelUsage {
    pub fn total_tokens(&self) -> u64 {
        self.input_tokens + self.output_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LongestSession {
    #[serde(default)]
    pub session_id: String,
    #[serde(default)]
    pub duration: u64,
    #[serde(default)]
    pub message_count: u64,
    #[serde(default)]
    pub timestamp: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_model_usage_total_tokens() {
        let mu = ModelUsage {
            input_tokens: 1000,
            output_tokens: 500,
            cache_read_input_tokens: 300,
            cache_creation_input_tokens: 200,
            web_search_requests: 0,
            cost_usd: 0.0,
            context_window: 0,
            max_output_tokens: 0,
        };
        assert_eq!(mu.total_tokens(), 2000);
    }

    #[test]
    fn test_model_usage_total_tokens_zero() {
        let mu = ModelUsage {
            input_tokens: 0,
            output_tokens: 0,
            cache_read_input_tokens: 0,
            cache_creation_input_tokens: 0,
            web_search_requests: 0,
            cost_usd: 0.0,
            context_window: 0,
            max_output_tokens: 0,
        };
        assert_eq!(mu.total_tokens(), 0);
    }

    #[test]
    fn test_deserialize_stats_cache_empty() {
        let json = r#"{"version":3}"#;
        let stats: StatsCache = serde_json::from_str(json).unwrap();
        assert_eq!(stats.version, 3);
        assert!(stats.daily_activity.is_empty());
        assert!(stats.model_usage.is_empty());
    }

    #[test]
    fn test_deserialize_stats_cache_full() {
        let json = r#"{
            "version": 3,
            "lastComputedDate": "2026-05-30",
            "totalSessions": 42,
            "totalMessages": 500,
            "firstSessionDate": "2025-11-04T08:34:16.259Z",
            "totalSpeculationTimeSavedMs": 12345,
            "modelUsage": {
                "opus": {
                    "inputTokens": 10000,
                    "outputTokens": 5000,
                    "cacheReadInputTokens": 3000,
                    "cacheCreationInputTokens": 1000,
                    "webSearchRequests": 10,
                    "costUsd": 1.5,
                    "contextWindow": 200000,
                    "maxOutputTokens": 8192
                }
            },
            "hourCounts": {"10": 50, "14": 80, "22": 30},
            "longestSession": {
                "sessionId": "abc123",
                "duration": 7200,
                "messageCount": 100,
                "timestamp": "2026-04-15T10:00:00Z"
            }
        }"#;
        let stats: StatsCache = serde_json::from_str(json).unwrap();
        assert_eq!(stats.version, 3);
        assert_eq!(stats.last_computed_date.as_deref(), Some("2026-05-30"));
        assert_eq!(stats.total_sessions, 42);
        assert_eq!(stats.total_messages, 500);
        assert_eq!(stats.first_session_date.as_deref(), Some("2025-11-04T08:34:16.259Z"));
        assert_eq!(stats.total_speculation_time_saved_ms, 12345);

        let opus = stats.model_usage.get("opus").unwrap();
        assert_eq!(opus.input_tokens, 10000);
        assert_eq!(opus.cost_usd, 1.5);
        assert_eq!(opus.context_window, 200000);

        assert_eq!(stats.hour_counts.get("14").unwrap(), &80);

        let longest = stats.longest_session.unwrap();
        assert_eq!(longest.session_id, "abc123");
        assert_eq!(longest.duration, 7200);
        assert_eq!(longest.message_count, 100);
    }

    #[test]
    fn test_deserialize_daily_model_tokens() {
        let json = r#"{"date":"2026-04-15","tokensByModel":{"opus":1000,"sonnet":500}}"#;
        let dmt: DailyModelTokens = serde_json::from_str(json).unwrap();
        assert_eq!(dmt.date, "2026-04-15");
        assert_eq!(dmt.tokens_by_model.get("opus").unwrap(), &1000);
        assert_eq!(dmt.tokens_by_model.get("sonnet").unwrap(), &500);
    }

    #[test]
    fn test_deserialize_daily_model_tokens_empty() {
        let json = r#"{"date":"2026-04-15","tokensByModel":{}}"#;
        let dmt: DailyModelTokens = serde_json::from_str(json).unwrap();
        assert_eq!(dmt.date, "2026-04-15");
        assert!(dmt.tokens_by_model.is_empty());
    }

    #[test]
    fn test_deserialize_stats_cache_with_daily_model_tokens() {
        let json = r#"{"version":3,"dailyModelTokens":[{"date":"2026-04-15","tokensByModel":{"opus":100}},{"date":"2026-04-16","tokensByModel":{"sonnet":200}}]}"#;
        let stats: StatsCache = serde_json::from_str(json).unwrap();
        assert_eq!(stats.daily_model_tokens.len(), 2);
        assert_eq!(stats.daily_model_tokens[0].date, "2026-04-15");
        assert_eq!(stats.daily_model_tokens[0].tokens_by_model.get("opus").unwrap(), &100);
    }

    #[test]
    fn test_deserialize_daily_activity() {
        let json = r#"{"date":"2026-04-15","messageCount":25,"sessionCount":3,"toolCallCount":10}"#;
        let da: DailyActivity = serde_json::from_str(json).unwrap();
        assert_eq!(da.date, "2026-04-15");
        assert_eq!(da.message_count, 25);
        assert_eq!(da.session_count, 3);
        assert_eq!(da.tool_call_count, 10);
    }

    #[test]
    fn test_deserialize_daily_activity_defaults() {
        let json = r#"{"date":"2026-04-15"}"#;
        let da: DailyActivity = serde_json::from_str(json).unwrap();
        assert_eq!(da.date, "2026-04-15");
        assert_eq!(da.message_count, 0);
        assert_eq!(da.session_count, 0);
        assert_eq!(da.tool_call_count, 0);
    }
}