Skip to main content

ccboard_core/analytics/
patterns.rs

1//! Usage pattern detection
2//!
3//! Identifies behavioral patterns: peak hours, productive days,
4//! model distribution, and session duration analytics.
5
6use 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/// Usage patterns
14#[derive(Debug, Clone)]
15pub struct UsagePatterns {
16    /// Most productive hour (0-23)
17    pub most_productive_hour: u8,
18    /// Most productive weekday
19    pub most_productive_day: Weekday,
20    /// Average session duration
21    pub avg_session_duration: Duration,
22    /// Most used model (by token count)
23    pub most_used_model: String,
24    /// Model distribution by token count (percentages)
25    pub model_distribution: HashMap<String, f64>,
26    /// Model distribution by cost (percentages)
27    pub model_cost_distribution: HashMap<String, f64>,
28    /// Peak hours (above 80th percentile)
29    pub peak_hours: Vec<u8>,
30    /// Hourly distribution (sessions per hour, 0-23)
31    pub hourly_distribution: [usize; 24],
32    /// Weekday distribution (sessions per weekday, 0-6)
33    pub weekday_distribution: [usize; 7],
34    /// Activity heatmap: [weekday][hour] = session count
35    /// weekday: 0-6 (Mon-Sun), hour: 0-23
36    pub activity_heatmap: [[usize; 24]; 7],
37    /// Tool usage statistics: tool name -> call count
38    pub tool_usage: HashMap<String, usize>,
39    /// Current consecutive-day usage streak (days ending today or yesterday)
40    pub current_streak_days: u32,
41    /// Longest consecutive-day streak across all loaded sessions
42    pub longest_streak_days: u32,
43}
44
45impl UsagePatterns {
46    /// Empty placeholder
47    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
66/// Estimate cost from session (same as trends.rs)
67fn estimate_cost(session: &SessionMetadata) -> f64 {
68    (session.total_tokens as f64 / 1000.0) * 0.01
69}
70
71/// Compute current and longest consecutive-day streaks across all sessions.
72///
73/// "Current streak" counts backward from today (or yesterday if no session today).
74/// "Longest streak" scans all active days.
75fn 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    // Current streak: walk backward from today (or yesterday if no session today)
90    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        // No activity today or yesterday — streak is 0
98        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    // Longest streak: scan sorted days
132    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    // current streak cannot exceed longest
150    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    // Filter by period (same logic as compute_trends)
172    let now = Local::now();
173    let cutoff = now - chrono::Duration::days(days as i64);
174
175    for session in sessions {
176        // Apply period filter check
177        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        // Hourly distribution & heatmap
189        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        // Tool usage stats - extracted from session metadata
200        for (tool_name, count) in &session.tool_usage {
201            *tool_usage.entry(tool_name.clone()).or_default() += count;
202        }
203
204        // Session duration
205        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        // Model distribution (tokens + cost)
213        // Divide tokens equally among models used in this session
214        if session.models_used.is_empty() {
215            // No model info: attribute to "unknown"
216            *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    // Most productive hour
232    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    // Most productive day
240    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    // Average duration
248    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    // Peak hours (80th percentile threshold)
255    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    // Model distribution (by tokens)
265    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    // Most used model
276    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    // Model cost distribution (NEW: cost-weighted)
283    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        // No session today, but one yesterday — current streak should be 1
364        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        // Sessions 2+ days ago only — current streak is 0
373        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        // Sessions for 5 consecutive days ending today
382        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        // Historical 7-day streak (10-16 days ago) + current 2-day streak (0-1 days ago)
391        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}