1use chrono::{Datelike, NaiveDate, Timelike, Weekday};
7use std::collections::{BTreeSet, HashMap};
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::models::session::SessionMetadata;
12
13#[derive(Debug, Clone)]
15pub struct UsagePatterns {
16 pub most_productive_hour: u8,
18 pub most_productive_day: Weekday,
20 pub avg_session_duration: Duration,
22 pub most_used_model: String,
24 pub model_distribution: HashMap<String, f64>,
26 pub model_cost_distribution: HashMap<String, f64>,
28 pub peak_hours: Vec<u8>,
30 pub hourly_distribution: [usize; 24],
32 pub weekday_distribution: [usize; 7],
34 pub activity_heatmap: [[usize; 24]; 7],
37 pub tool_usage: HashMap<String, usize>,
39 pub current_streak_days: u32,
41 pub longest_streak_days: u32,
43}
44
45impl UsagePatterns {
46 pub fn empty() -> Self {
48 Self {
49 most_productive_hour: 0,
50 most_productive_day: Weekday::Mon,
51 avg_session_duration: Duration::from_secs(0),
52 most_used_model: "unknown".to_string(),
53 model_distribution: HashMap::new(),
54 model_cost_distribution: HashMap::new(),
55 peak_hours: Vec::new(),
56 hourly_distribution: [0; 24],
57 weekday_distribution: [0; 7],
58 activity_heatmap: [[0; 24]; 7],
59 tool_usage: HashMap::new(),
60 current_streak_days: 0,
61 longest_streak_days: 0,
62 }
63 }
64}
65
66fn estimate_cost(session: &SessionMetadata) -> f64 {
68 (session.total_tokens as f64 / 1000.0) * 0.01
69}
70
71fn compute_streaks(sessions: &[Arc<SessionMetadata>]) -> (u32, u32) {
76 use chrono::Local;
77
78 let mut active_days: BTreeSet<NaiveDate> = BTreeSet::new();
79 for session in sessions {
80 if let Some(ts) = session.first_timestamp {
81 active_days.insert(ts.with_timezone(&Local).date_naive());
82 }
83 }
84
85 if active_days.is_empty() {
86 return (0, 0);
87 }
88
89 let today = Local::now().date_naive();
91 let yesterday = today - chrono::Duration::days(1);
92 let start = if active_days.contains(&today) {
93 today
94 } else if active_days.contains(&yesterday) {
95 yesterday
96 } else {
97 let longest = {
99 let days_vec: Vec<NaiveDate> = active_days.into_iter().collect();
100 let mut longest = 0u32;
101 let mut streak = 0u32;
102 let mut prev: Option<NaiveDate> = None;
103 for day in &days_vec {
104 if let Some(p) = prev {
105 if *day == p + chrono::Duration::days(1) {
106 streak += 1;
107 } else {
108 streak = 1;
109 }
110 } else {
111 streak = 1;
112 }
113 longest = longest.max(streak);
114 prev = Some(*day);
115 }
116 longest
117 };
118 return (0, longest);
119 };
120 let mut current = 0u32;
121 let mut check = start;
122 loop {
123 if active_days.contains(&check) {
124 current += 1;
125 check -= chrono::Duration::days(1);
126 } else {
127 break;
128 }
129 }
130
131 let days_vec: Vec<NaiveDate> = active_days.into_iter().collect();
133 let mut longest = 0u32;
134 let mut streak = 0u32;
135 let mut prev: Option<NaiveDate> = None;
136 for day in &days_vec {
137 if let Some(p) = prev {
138 if *day == p + chrono::Duration::days(1) {
139 streak += 1;
140 } else {
141 streak = 1;
142 }
143 } else {
144 streak = 1;
145 }
146 longest = longest.max(streak);
147 prev = Some(*day);
148 }
149 let current = current.min(longest);
151
152 (current, longest)
153}
154
155pub fn detect_patterns(sessions: &[Arc<SessionMetadata>], days: usize) -> UsagePatterns {
156 use chrono::Local;
157
158 if sessions.is_empty() {
159 return UsagePatterns::empty();
160 }
161
162 let mut hourly_counts = [0usize; 24];
163 let mut weekday_counts = [0usize; 7];
164 let mut activity_heatmap = [[0usize; 24]; 7];
165 let mut tool_usage: HashMap<String, usize> = HashMap::new();
166 let mut total_duration = Duration::from_secs(0);
167 let mut duration_count = 0usize;
168 let mut model_tokens: HashMap<String, f64> = HashMap::new();
169 let mut model_costs: HashMap<String, f64> = HashMap::new();
170
171 let now = Local::now();
173 let cutoff = now - chrono::Duration::days(days as i64);
174
175 for session in sessions {
176 let passes_filter = if let Some(ts) = session.first_timestamp {
178 let local_ts = ts.with_timezone(&Local);
179 local_ts >= cutoff
180 } else {
181 false
182 };
183
184 if !passes_filter {
185 continue;
186 }
187
188 if let Some(ts) = session.first_timestamp {
190 let local_ts = ts.with_timezone(&Local);
191 let hour = local_ts.hour() as usize;
192 let weekday = local_ts.weekday().num_days_from_monday() as usize;
193
194 hourly_counts[hour] += 1;
195 weekday_counts[weekday] += 1;
196 activity_heatmap[weekday][hour] += 1;
197 }
198
199 for (tool_name, count) in &session.tool_usage {
201 *tool_usage.entry(tool_name.clone()).or_default() += count;
202 }
203
204 if let (Some(start), Some(end)) = (session.first_timestamp, session.last_timestamp) {
206 if let Ok(duration) = (end - start).to_std() {
207 total_duration += duration;
208 duration_count += 1;
209 }
210 }
211
212 if session.models_used.is_empty() {
215 *model_tokens.entry("unknown".to_string()).or_default() += session.total_tokens as f64;
217 *model_costs.entry("unknown".to_string()).or_default() += estimate_cost(session);
218 } else {
219 let models_count = session.models_used.len() as f64;
220 let tokens_per_model = session.total_tokens as f64 / models_count;
221 let cost = estimate_cost(session);
222 let cost_per_model = cost / models_count;
223
224 for model in &session.models_used {
225 *model_tokens.entry(model.clone()).or_default() += tokens_per_model;
226 *model_costs.entry(model.clone()).or_default() += cost_per_model;
227 }
228 }
229 }
230
231 let most_productive_hour = hourly_counts
233 .iter()
234 .enumerate()
235 .max_by_key(|(_, count)| *count)
236 .map(|(hour, _)| hour as u8)
237 .unwrap_or(0);
238
239 let most_productive_day = weekday_counts
241 .iter()
242 .enumerate()
243 .max_by_key(|(_, count)| *count)
244 .and_then(|(idx, _)| Weekday::try_from(idx as u8).ok())
245 .unwrap_or(Weekday::Mon);
246
247 let avg_session_duration = if duration_count > 0 {
249 total_duration / duration_count as u32
250 } else {
251 Duration::from_secs(0)
252 };
253
254 let total_sessions: usize = hourly_counts.iter().sum();
256 let threshold = (total_sessions as f64 * 0.8 / 24.0) as usize;
257 let peak_hours: Vec<u8> = hourly_counts
258 .iter()
259 .enumerate()
260 .filter(|(_, count)| **count > threshold)
261 .map(|(hour, _)| hour as u8)
262 .collect();
263
264 let total_tokens: f64 = model_tokens.values().sum();
266 let model_distribution: HashMap<String, f64> = if total_tokens > 0.0 {
267 model_tokens
268 .into_iter()
269 .map(|(model, tokens)| (model, tokens / total_tokens))
270 .collect()
271 } else {
272 HashMap::new()
273 };
274
275 let most_used_model = model_distribution
277 .iter()
278 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
279 .map(|(model, _)| model.clone())
280 .unwrap_or_else(|| "unknown".to_string());
281
282 let total_cost: f64 = model_costs.values().sum();
284 let model_cost_distribution: HashMap<String, f64> = if total_cost > 0.0 {
285 model_costs
286 .into_iter()
287 .map(|(model, cost)| (model, cost / total_cost))
288 .collect()
289 } else {
290 HashMap::new()
291 };
292
293 let (current_streak_days, longest_streak_days) = compute_streaks(sessions);
294
295 UsagePatterns {
296 most_productive_hour,
297 most_productive_day,
298 avg_session_duration,
299 most_used_model,
300 model_distribution,
301 model_cost_distribution,
302 peak_hours,
303 hourly_distribution: hourly_counts,
304 weekday_distribution: weekday_counts,
305 activity_heatmap,
306 tool_usage,
307 current_streak_days,
308 longest_streak_days,
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn session_on_days_ago(days_ago: i64) -> Arc<SessionMetadata> {
317 let ts = chrono::Utc::now() - chrono::Duration::days(days_ago);
318 Arc::new(SessionMetadata {
319 id: format!("s{}", days_ago).into(),
320 file_path: std::path::PathBuf::from(format!("/tmp/s{}.jsonl", days_ago)),
321 project_path: "test".into(),
322 first_timestamp: Some(ts),
323 last_timestamp: Some(ts),
324 message_count: 1,
325 total_tokens: 100,
326 input_tokens: 50,
327 output_tokens: 50,
328 cache_creation_tokens: 0,
329 cache_read_tokens: 0,
330 models_used: vec![],
331 model_segments: Vec::new(),
332 file_size_bytes: 256,
333 first_user_message: None,
334 has_subagents: false,
335 parent_session_id: None,
336 duration_seconds: Some(10),
337 branch: None,
338 tool_usage: std::collections::HashMap::new(),
339 tool_token_usage: std::collections::HashMap::new(),
340 source_tool: Default::default(),
341 lines_added: 0,
342 lines_removed: 0,
343 })
344 }
345
346 #[test]
347 fn test_streak_empty_sessions() {
348 let (current, longest) = compute_streaks(&[]);
349 assert_eq!(current, 0);
350 assert_eq!(longest, 0);
351 }
352
353 #[test]
354 fn test_streak_single_today() {
355 let sessions = vec![session_on_days_ago(0)];
356 let (current, longest) = compute_streaks(&sessions);
357 assert_eq!(current, 1);
358 assert_eq!(longest, 1);
359 }
360
361 #[test]
362 fn test_streak_yesterday_only() {
363 let sessions = vec![session_on_days_ago(1)];
365 let (current, longest) = compute_streaks(&sessions);
366 assert_eq!(current, 1);
367 assert_eq!(longest, 1);
368 }
369
370 #[test]
371 fn test_streak_gap_breaks_current() {
372 let sessions = vec![session_on_days_ago(3), session_on_days_ago(4)];
374 let (current, longest) = compute_streaks(&sessions);
375 assert_eq!(current, 0);
376 assert_eq!(longest, 2);
377 }
378
379 #[test]
380 fn test_streak_consecutive_days() {
381 let sessions: Vec<_> = (0..5).map(session_on_days_ago).collect();
383 let (current, longest) = compute_streaks(&sessions);
384 assert_eq!(current, 5);
385 assert_eq!(longest, 5);
386 }
387
388 #[test]
389 fn test_streak_longer_historical_than_current() {
390 let mut sessions: Vec<_> = (0..=1).map(session_on_days_ago).collect();
392 sessions.extend((10..=16).map(session_on_days_ago));
393 let (current, longest) = compute_streaks(&sessions);
394 assert_eq!(current, 2);
395 assert_eq!(longest, 7);
396 }
397}