Skip to main content

ccboard_types/analytics/
trends.rs

1//! Time series trends analysis
2//!
3//! Aggregates session data by day, hour, and weekday to identify usage patterns over time.
4
5use chrono::{Datelike, Local, Timelike};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9use crate::models::session::SessionMetadata;
10
11/// Session duration statistics
12#[derive(Debug, Clone)]
13pub struct SessionDurationStats {
14    /// Average duration in seconds
15    pub avg_duration_secs: f64,
16    /// Median duration in seconds
17    pub median_duration_secs: f64,
18    /// 95th percentile duration in seconds
19    pub p95_duration_secs: f64,
20    /// Shortest session in seconds
21    pub shortest_session_secs: u64,
22    /// Longest session in seconds
23    pub longest_session_secs: u64,
24    /// Distribution buckets (0-5m, 5-15m, 15-30m, 30-60m, 60m+)
25    pub distribution: [usize; 5],
26}
27
28impl SessionDurationStats {
29    /// Empty placeholder (no sessions with duration data)
30    pub fn empty() -> Self {
31        Self {
32            avg_duration_secs: 0.0,
33            median_duration_secs: 0.0,
34            p95_duration_secs: 0.0,
35            shortest_session_secs: 0,
36            longest_session_secs: 0,
37            distribution: [0; 5],
38        }
39    }
40}
41
42/// Time series trends data
43#[derive(Debug, Clone)]
44pub struct TrendsData {
45    /// Dates in "YYYY-MM-DD" format (sorted chronologically)
46    pub dates: Vec<String>,
47    /// Daily token counts (aligned with dates)
48    pub daily_tokens: Vec<u64>,
49    /// Daily session counts (aligned with dates)
50    pub daily_sessions: Vec<usize>,
51    /// Daily cost estimates (aligned with dates)
52    pub daily_cost: Vec<f64>,
53    /// Hourly distribution (0-23)
54    pub hourly_distribution: [usize; 24],
55    /// Weekday distribution (0=Monday, 6=Sunday)
56    pub weekday_distribution: [usize; 7],
57    /// Model usage over time (aligned with dates)
58    pub model_usage_over_time: HashMap<String, Vec<usize>>,
59    /// Session duration statistics
60    pub duration_stats: SessionDurationStats,
61}
62
63impl TrendsData {
64    /// Check if empty (no data in period)
65    pub fn is_empty(&self) -> bool {
66        self.dates.is_empty()
67    }
68
69    /// Get tokens at specific date index
70    pub fn get_tokens_at(&self, idx: usize) -> Option<(&str, u64)> {
71        Some((self.dates.get(idx)?, self.daily_tokens[idx]))
72    }
73
74    /// Empty placeholder for no data
75    pub fn empty() -> Self {
76        Self {
77            dates: Vec::new(),
78            daily_tokens: Vec::new(),
79            daily_sessions: Vec::new(),
80            daily_cost: Vec::new(),
81            hourly_distribution: [0; 24],
82            weekday_distribution: [0; 7],
83            model_usage_over_time: HashMap::new(),
84            duration_stats: SessionDurationStats::empty(),
85        }
86    }
87}
88
89/// Daily aggregate helper
90#[derive(Default)]
91struct DailyAggregate {
92    tokens: u64,
93    sessions: usize,
94    cost: f64,
95}
96
97/// Estimate cost from session
98///
99/// TODO: Integrate with StatsCache.model_pricing when available
100/// Currently uses placeholder: $0.01 per 1K tokens
101fn estimate_cost(session: &SessionMetadata) -> f64 {
102    (session.total_tokens as f64 / 1000.0) * 0.01
103}
104
105/// Compute trends from sessions
106///
107/// Aggregates sessions by local date, hour, weekday and model.
108/// Converts UTC timestamps to local timezone for grouping.
109///
110/// # Performance
111/// Target: <40ms for 1000 sessions
112///
113/// # Graceful Degradation
114/// - Missing timestamps: Session skipped with warning
115/// - Empty models_used: Counted but not tracked per-model
116pub fn compute_trends(sessions: &[Arc<SessionMetadata>], days: usize) -> TrendsData {
117    let mut daily_map: BTreeMap<String, DailyAggregate> = BTreeMap::new();
118    let mut hourly_counts = [0usize; 24];
119    let mut weekday_counts = [0usize; 7];
120    let mut model_usage: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
121    let mut durations_secs: Vec<u64> = Vec::new();
122
123    let now = Local::now();
124    let cutoff = now - chrono::Duration::days(days as i64);
125
126    for session in sessions {
127        let Some(ts) = session.first_timestamp else {
128            // Skip sessions without timestamps (graceful degradation)
129            continue;
130        };
131
132        // Convert UTC → Local for grouping
133        let local_ts = ts.with_timezone(&Local);
134
135        // Filter by period
136        if local_ts < cutoff {
137            continue;
138        }
139
140        let date_key = local_ts.format("%Y-%m-%d").to_string();
141
142        // Aggregate daily
143        let agg = daily_map.entry(date_key.clone()).or_default();
144        agg.tokens += session.total_tokens;
145        agg.sessions += 1;
146        agg.cost += estimate_cost(session);
147
148        // Hourly distribution
149        hourly_counts[local_ts.hour() as usize] += 1;
150
151        // Weekday distribution (0 = Monday, 6 = Sunday)
152        weekday_counts[local_ts.weekday().num_days_from_monday() as usize] += 1;
153
154        // Model usage over time
155        for model in &session.models_used {
156            model_usage
157                .entry(model.clone())
158                .or_default()
159                .entry(date_key.clone())
160                .and_modify(|count| *count += 1)
161                .or_insert(1);
162        }
163
164        // Session duration
165        if let (Some(start), Some(end)) = (session.first_timestamp, session.last_timestamp) {
166            if let Ok(duration) = (end - start).num_seconds().try_into() {
167                durations_secs.push(duration);
168            }
169        }
170    }
171
172    // Extract sorted dates + values
173    let dates: Vec<String> = daily_map.keys().cloned().collect();
174    let daily_tokens: Vec<u64> = daily_map.values().map(|a| a.tokens).collect();
175    let daily_sessions: Vec<usize> = daily_map.values().map(|a| a.sessions).collect();
176    let daily_cost: Vec<f64> = daily_map.values().map(|a| a.cost).collect();
177
178    // Align model usage with dates
179    let model_usage_over_time: HashMap<String, Vec<usize>> = model_usage
180        .into_iter()
181        .map(|(model, date_map)| {
182            let counts = dates
183                .iter()
184                .map(|d| *date_map.get(d).unwrap_or(&0))
185                .collect();
186            (model, counts)
187        })
188        .collect();
189
190    // Compute session duration statistics
191    let duration_stats = compute_duration_stats(&durations_secs);
192
193    TrendsData {
194        dates,
195        daily_tokens,
196        daily_sessions,
197        daily_cost,
198        hourly_distribution: hourly_counts,
199        weekday_distribution: weekday_counts,
200        model_usage_over_time,
201        duration_stats,
202    }
203}
204
205/// Compute session duration statistics
206fn compute_duration_stats(durations: &[u64]) -> SessionDurationStats {
207    if durations.is_empty() {
208        return SessionDurationStats::empty();
209    }
210
211    let mut sorted = durations.to_vec();
212    sorted.sort_unstable();
213
214    let shortest = sorted[0];
215    let longest = sorted[sorted.len() - 1];
216
217    // Average
218    let sum: u64 = sorted.iter().sum();
219    let avg = sum as f64 / sorted.len() as f64;
220
221    // Median
222    let median = if sorted.len() % 2 == 0 {
223        let mid = sorted.len() / 2;
224        (sorted[mid - 1] + sorted[mid]) as f64 / 2.0
225    } else {
226        sorted[sorted.len() / 2] as f64
227    };
228
229    // P95 (95th percentile)
230    let p95_idx = ((sorted.len() as f64) * 0.95).ceil() as usize - 1;
231    let p95 = sorted.get(p95_idx).copied().unwrap_or(longest) as f64;
232
233    // Distribution buckets: 0-5m, 5-15m, 15-30m, 30-60m, 60m+
234    let mut distribution = [0usize; 5];
235    for &duration in durations {
236        let minutes = duration / 60;
237        let bucket = match minutes {
238            0..=4 => 0,   // 0-5m
239            5..=14 => 1,  // 5-15m
240            15..=29 => 2, // 15-30m
241            30..=59 => 3, // 30-60m
242            _ => 4,       // 60m+
243        };
244        distribution[bucket] += 1;
245    }
246
247    SessionDurationStats {
248        avg_duration_secs: avg,
249        median_duration_secs: median,
250        p95_duration_secs: p95,
251        shortest_session_secs: shortest,
252        longest_session_secs: longest,
253        distribution,
254    }
255}