ccboard_types/analytics/
trends.rs1use chrono::{Datelike, Local, Timelike};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9use crate::models::session::SessionMetadata;
10
11#[derive(Debug, Clone)]
13pub struct SessionDurationStats {
14 pub avg_duration_secs: f64,
16 pub median_duration_secs: f64,
18 pub p95_duration_secs: f64,
20 pub shortest_session_secs: u64,
22 pub longest_session_secs: u64,
24 pub distribution: [usize; 5],
26}
27
28impl SessionDurationStats {
29 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#[derive(Debug, Clone)]
44pub struct TrendsData {
45 pub dates: Vec<String>,
47 pub daily_tokens: Vec<u64>,
49 pub daily_sessions: Vec<usize>,
51 pub daily_cost: Vec<f64>,
53 pub hourly_distribution: [usize; 24],
55 pub weekday_distribution: [usize; 7],
57 pub model_usage_over_time: HashMap<String, Vec<usize>>,
59 pub duration_stats: SessionDurationStats,
61}
62
63impl TrendsData {
64 pub fn is_empty(&self) -> bool {
66 self.dates.is_empty()
67 }
68
69 pub fn get_tokens_at(&self, idx: usize) -> Option<(&str, u64)> {
71 Some((self.dates.get(idx)?, self.daily_tokens[idx]))
72 }
73
74 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#[derive(Default)]
91struct DailyAggregate {
92 tokens: u64,
93 sessions: usize,
94 cost: f64,
95}
96
97fn estimate_cost(session: &SessionMetadata) -> f64 {
102 (session.total_tokens as f64 / 1000.0) * 0.01
103}
104
105pub 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 continue;
130 };
131
132 let local_ts = ts.with_timezone(&Local);
134
135 if local_ts < cutoff {
137 continue;
138 }
139
140 let date_key = local_ts.format("%Y-%m-%d").to_string();
141
142 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_counts[local_ts.hour() as usize] += 1;
150
151 weekday_counts[local_ts.weekday().num_days_from_monday() as usize] += 1;
153
154 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 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 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 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 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
205fn 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 let sum: u64 = sorted.iter().sum();
219 let avg = sum as f64 / sorted.len() as f64;
220
221 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 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 let mut distribution = [0usize; 5];
235 for &duration in durations {
236 let minutes = duration / 60;
237 let bucket = match minutes {
238 0..=4 => 0, 5..=14 => 1, 15..=29 => 2, 30..=59 => 3, _ => 4, };
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}