Skip to main content

ccboard_core/analytics/
mod.rs

1//! Advanced analytics module for Claude Code usage analysis
2//!
3//! Provides time series trends, forecasting, usage pattern detection,
4//! and actionable insights to optimize costs and productivity.
5
6use chrono::{DateTime, Utc};
7use std::sync::Arc;
8
9use crate::models::session::SessionMetadata;
10
11pub mod anomalies;
12pub mod discover;
13pub mod discover_llm;
14pub mod forecasting;
15pub mod insights;
16pub mod optimization;
17pub mod patterns;
18pub mod plugin_usage;
19pub mod tool_chains;
20pub mod trends;
21
22#[cfg(test)]
23mod tests;
24
25pub use anomalies::{
26    detect_anomalies, detect_daily_cost_spikes, Anomaly, AnomalyMetric, AnomalySeverity,
27    DailyCostAnomaly,
28};
29pub use discover::{
30    collect_sessions_data as discover_collect_sessions, discover_patterns, run_discover,
31    DiscoverConfig, DiscoverSuggestion, SessionData as DiscoverSessionData, SuggestionCategory,
32};
33pub use discover_llm::{call_claude_cli as discover_call_llm, LlmSuggestion};
34pub use forecasting::{forecast_usage, ForecastData, TrendDirection};
35pub use insights::{generate_budget_alerts, generate_insights, Alert};
36pub use optimization::{
37    generate_cost_suggestions, generate_model_recommendations, CostSuggestion, OptimizationCategory,
38};
39pub use patterns::{detect_patterns, UsagePatterns};
40pub use plugin_usage::{aggregate_plugin_usage, PluginAnalytics, PluginType, PluginUsage};
41pub use tool_chains::{analyze_tool_chains, ToolChain, ToolChainAnalysis};
42pub use trends::{compute_trends, SessionDurationStats, TrendsData};
43
44/// Period selection for analytics computation
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum Period {
47    /// Last N days from now
48    Days(usize),
49    /// All loaded sessions (honest: not "all time", limited by DataStore)
50    Available,
51}
52
53impl Period {
54    /// Last 7 days
55    pub fn last_7d() -> Self {
56        Self::Days(7)
57    }
58
59    /// Last 30 days
60    pub fn last_30d() -> Self {
61        Self::Days(30)
62    }
63
64    /// Last 90 days
65    pub fn last_90d() -> Self {
66        Self::Days(90)
67    }
68
69    /// All available sessions
70    pub fn available() -> Self {
71        Self::Available
72    }
73
74    /// Convert to days (for filtering)
75    pub fn days(&self) -> usize {
76        match self {
77            Period::Days(n) => *n,
78            Period::Available => 36500, // 100 years (effectively all)
79        }
80    }
81
82    /// Display label (shows loaded count for Available)
83    pub fn display(&self, total_loaded: usize) -> String {
84        match self {
85            Period::Days(n) => format!("Last {} days", n),
86            Period::Available => format!("All loaded ({} sessions)", total_loaded),
87        }
88    }
89}
90
91/// Complete analytics data for a period
92#[derive(Debug, Clone)]
93pub struct AnalyticsData {
94    /// Time series trends
95    pub trends: TrendsData,
96    /// Usage forecasting
97    pub forecast: ForecastData,
98    /// Behavioral patterns
99    pub patterns: UsagePatterns,
100    /// Actionable insights
101    pub insights: Vec<String>,
102    /// Tool chain bigram/trigram analysis
103    pub tool_chains: Option<ToolChainAnalysis>,
104    /// Cost optimization suggestions
105    pub cost_suggestions: Vec<optimization::CostSuggestion>,
106    /// Session-level anomalies (Z-score based)
107    pub anomalies: Vec<anomalies::Anomaly>,
108    /// Daily cost spikes
109    pub daily_spikes: Vec<anomalies::DailyCostAnomaly>,
110    /// Number of sessions in the analyzed period
111    pub sessions_in_period: usize,
112    /// Timestamp of computation
113    pub computed_at: DateTime<Utc>,
114    /// Period analyzed
115    pub period: Period,
116}
117
118impl AnalyticsData {
119    /// Compute analytics from sessions (sync function)
120    ///
121    /// This is a sync function for simplicity. If computation exceeds 16ms
122    /// (render deadline), caller should offload to `tokio::task::spawn_blocking`.
123    ///
124    /// # Performance
125    /// Target: <100ms for 1000 sessions over 30 days
126    pub fn compute(sessions: &[Arc<SessionMetadata>], period: Period) -> Self {
127        use chrono::Local;
128
129        let trends = compute_trends(sessions, period.days());
130        let forecast = forecast_usage(&trends);
131        let patterns = detect_patterns(sessions, period.days());
132        let insights = generate_insights(&trends, &patterns, &forecast);
133
134        // Filter sessions to the period for anomaly detection
135        let cutoff = Local::now() - chrono::Duration::days(period.days() as i64);
136        let period_sessions: Vec<Arc<SessionMetadata>> = sessions
137            .iter()
138            .filter(|s| {
139                s.first_timestamp
140                    .map(|ts| ts.with_timezone(&Local) >= cutoff)
141                    .unwrap_or(false)
142            })
143            .cloned()
144            .collect();
145
146        let sessions_in_period = period_sessions.len();
147        let anomalies_detected = anomalies::detect_anomalies(&period_sessions);
148        let daily_spikes_detected =
149            anomalies::detect_daily_cost_spikes(&period_sessions, period.days());
150
151        // Aggregate per-tool token usage across all sessions
152        let mut aggregated_tool_tokens: std::collections::HashMap<String, u64> =
153            std::collections::HashMap::new();
154        for session in sessions {
155            for (tool, &tokens) in &session.tool_token_usage {
156                *aggregated_tool_tokens.entry(tool.clone()).or_default() += tokens;
157            }
158        }
159
160        // Estimate period cost from trend data
161        let total_cost_estimate: f64 = trends.daily_cost.iter().sum();
162
163        // Generate cost suggestions (plugin_analytics populated with empty data here;
164        // full plugin analytics with dead-code detection requires skill/command lists
165        // which are provided by DataStore when calling the analytics tab)
166        let mut cost_suggestions = optimization::generate_cost_suggestions(
167            &plugin_usage::PluginAnalytics::empty(),
168            &aggregated_tool_tokens,
169            total_cost_estimate,
170        );
171
172        // Append model downgrade recommendations
173        let model_recs =
174            optimization::generate_model_recommendations(sessions, total_cost_estimate);
175        cost_suggestions.extend(model_recs);
176        // Re-sort by potential savings descending after merge
177        cost_suggestions.sort_by(|a, b| {
178            b.potential_savings
179                .partial_cmp(&a.potential_savings)
180                .unwrap_or(std::cmp::Ordering::Equal)
181        });
182
183        Self {
184            trends,
185            forecast,
186            patterns,
187            insights,
188            tool_chains: Some(analyze_tool_chains(sessions)),
189            cost_suggestions,
190            anomalies: anomalies_detected,
191            daily_spikes: daily_spikes_detected,
192            sessions_in_period,
193            computed_at: Utc::now(),
194            period,
195        }
196    }
197
198    /// Graceful fallback if stats-cache.json missing
199    ///
200    /// Cost forecasting requires pricing data from StatsCache.
201    /// If unavailable, returns limited analytics with warning.
202    pub fn from_sessions_only(sessions: &[Arc<SessionMetadata>], period: Period) -> Self {
203        tracing::warn!("Stats cache missing, computing analytics from sessions only");
204
205        Self {
206            trends: compute_trends(sessions, period.days()),
207            forecast: ForecastData::unavailable("Stats cache required for cost forecasting"),
208            patterns: detect_patterns(sessions, period.days()),
209            insights: vec!["Limited insights: stats cache unavailable".to_string()],
210            tool_chains: Some(analyze_tool_chains(sessions)),
211            cost_suggestions: Vec::new(),
212            anomalies: Vec::new(),
213            daily_spikes: Vec::new(),
214            sessions_in_period: sessions.len(),
215            computed_at: Utc::now(),
216            period,
217        }
218    }
219}