Skip to main content

cc_token_usage/analysis/
heatmap.rs

1use std::collections::HashMap;
2
3use chrono::{Local, NaiveDate};
4
5use crate::data::models::SessionData;
6use crate::pricing::calculator::PricingCalculator;
7
8// ─── Result Types ──────────────────────────────────────────────────────────
9
10#[derive(Debug)]
11pub struct HeatmapResult {
12    pub daily: Vec<DailyActivity>,
13    /// Start date of the range (inclusive)
14    pub start_date: NaiveDate,
15    /// End date of the range (inclusive, always today in local time)
16    pub end_date: NaiveDate,
17    /// Percentile thresholds (P25, P50, P75) computed from non-zero days
18    pub thresholds: (usize, usize, usize),
19    /// Streak statistics
20    pub stats: HeatmapStats,
21}
22
23#[derive(Debug, Clone)]
24pub struct DailyActivity {
25    pub date: NaiveDate,
26    pub turns: usize,
27    pub cost: f64,
28    pub sessions: usize,
29}
30
31#[derive(Debug)]
32pub struct HeatmapStats {
33    pub total_days: usize,
34    pub active_days: usize,
35    pub current_streak: usize,
36    pub longest_streak: usize,
37    pub busiest_day: Option<(NaiveDate, usize)>,
38}
39
40// ─── Analysis ──────────────────────────────────────────────────────────────
41
42pub fn analyze_heatmap(
43    sessions: &[SessionData],
44    calc: &PricingCalculator,
45    days: u32,
46) -> HeatmapResult {
47    let today = Local::now().date_naive();
48
49    let start_date = if days == 0 {
50        // All history: find earliest timestamp across all sessions
51        sessions
52            .iter()
53            .filter_map(|s| s.first_timestamp)
54            .map(|ts| ts.with_timezone(&Local).date_naive())
55            .min()
56            .unwrap_or(today)
57    } else {
58        today - chrono::Duration::days(days as i64 - 1)
59    };
60
61    // Aggregate turns per day
62    let mut day_map: HashMap<NaiveDate, (usize, f64, usize)> = HashMap::new();
63
64    // Count sessions per day (by first_timestamp)
65    for session in sessions {
66        if let Some(first_ts) = session.first_timestamp {
67            let date = first_ts.with_timezone(&Local).date_naive();
68            if date >= start_date && date <= today {
69                day_map.entry(date).or_default().2 += 1;
70            }
71        }
72
73        // Count turns and cost per day
74        for turn in session.all_responses() {
75            let date = turn.timestamp.with_timezone(&Local).date_naive();
76            if date < start_date || date > today {
77                continue;
78            }
79            let entry = day_map.entry(date).or_default();
80            entry.0 += 1;
81            entry.1 += calc.calculate_turn_cost(&turn.model, &turn.usage).total;
82        }
83    }
84
85    // Build daily activity vector for the full range
86    let mut daily = Vec::new();
87    let mut d = start_date;
88    while d <= today {
89        let (turns, cost, sessions) = day_map.get(&d).copied().unwrap_or_default();
90        daily.push(DailyActivity {
91            date: d,
92            turns,
93            cost,
94            sessions,
95        });
96        d += chrono::Duration::days(1);
97    }
98
99    // Compute percentile thresholds from non-zero days
100    let thresholds = compute_thresholds(&daily);
101
102    // Compute streak stats
103    let stats = compute_stats(&daily, today);
104
105    HeatmapResult {
106        daily,
107        start_date,
108        end_date: today,
109        thresholds,
110        stats,
111    }
112}
113
114fn compute_thresholds(daily: &[DailyActivity]) -> (usize, usize, usize) {
115    let mut non_zero: Vec<usize> = daily
116        .iter()
117        .filter(|d| d.turns > 0)
118        .map(|d| d.turns)
119        .collect();
120    if non_zero.is_empty() {
121        return (1, 2, 3);
122    }
123    non_zero.sort_unstable();
124    let len = non_zero.len();
125    let p25 = non_zero[(len as f64 * 0.25) as usize];
126    let p50 = non_zero[(len as f64 * 0.50).min((len - 1) as f64) as usize];
127    let p75 = non_zero[(len as f64 * 0.75).min((len - 1) as f64) as usize];
128
129    // Ensure thresholds are at least 1 and strictly increasing where possible
130    let p25 = p25.max(1);
131    let p50 = p50.max(p25);
132    let p75 = p75.max(p50);
133
134    (p25, p50, p75)
135}
136
137fn compute_stats(daily: &[DailyActivity], today: NaiveDate) -> HeatmapStats {
138    let total_days = daily.len();
139    let active_days = daily.iter().filter(|d| d.turns > 0).count();
140
141    // Find busiest day
142    let busiest_day = daily
143        .iter()
144        .filter(|d| d.turns > 0)
145        .max_by_key(|d| d.turns)
146        .map(|d| (d.date, d.turns));
147
148    // Current streak: count consecutive active days ending at today
149    let current_streak = {
150        let mut streak = 0usize;
151        for d in daily.iter().rev() {
152            if d.date > today {
153                continue;
154            }
155            if d.turns > 0 {
156                streak += 1;
157            } else {
158                break;
159            }
160        }
161        streak
162    };
163
164    // Longest streak
165    let longest_streak = {
166        let mut longest = 0usize;
167        let mut current = 0usize;
168        for d in daily {
169            if d.turns > 0 {
170                current += 1;
171                if current > longest {
172                    longest = current;
173                }
174            } else {
175                current = 0;
176            }
177        }
178        longest
179    };
180
181    HeatmapStats {
182        total_days,
183        active_days,
184        current_streak,
185        longest_streak,
186        busiest_day,
187    }
188}
189
190// ─── Tests ─────────────────────────────────────────────────────────────────
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::data::models::{
196        DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
197    };
198    use chrono::Utc;
199
200    fn make_turn(ts: &str) -> ValidatedTurn {
201        ValidatedTurn {
202            uuid: "u1".to_string(),
203            request_id: None,
204            timestamp: ts.parse::<DateTime<Utc>>().unwrap_or_else(|_| Utc::now()),
205            model: "claude-sonnet-4-20250514".to_string(),
206            usage: TokenUsage {
207                input_tokens: Some(100),
208                output_tokens: Some(50),
209                cache_creation_input_tokens: Some(0),
210                cache_read_input_tokens: Some(0),
211                cache_creation: None,
212                server_tool_use: None,
213                service_tier: None,
214                speed: None,
215                inference_geo: None,
216            },
217            stop_reason: Some("end_turn".to_string()),
218            content_types: vec!["text".to_string()],
219            is_agent: false,
220            agent_id: None,
221            user_text: None,
222            assistant_text: None,
223            tool_names: vec![],
224            service_tier: None,
225            speed: None,
226            inference_geo: None,
227            tool_error_count: 0,
228            git_branch: None,
229        }
230    }
231
232    use chrono::DateTime;
233
234    fn make_session(id: &str, turns: Vec<ValidatedTurn>) -> SessionData {
235        let first = turns.first().map(|t| t.timestamp);
236        let last = turns.last().map(|t| t.timestamp);
237        SessionData {
238            session_id: id.to_string(),
239            project: Some("test-project".to_string()),
240            turns,
241            agent_turns: vec![],
242            first_timestamp: first,
243            last_timestamp: last,
244            version: None,
245            quality: DataQuality::default(),
246            metadata: SessionMetadata::default(),
247        }
248    }
249
250    #[test]
251    fn test_thresholds_empty() {
252        let daily = vec![];
253        let (p25, p50, p75) = compute_thresholds(&daily);
254        assert!(p25 >= 1);
255        assert!(p50 >= p25);
256        assert!(p75 >= p50);
257    }
258
259    #[test]
260    fn test_thresholds_uniform() {
261        let daily: Vec<DailyActivity> = (0..10)
262            .map(|i| DailyActivity {
263                date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i),
264                turns: 5,
265                cost: 0.0,
266                sessions: 1,
267            })
268            .collect();
269        let (p25, p50, p75) = compute_thresholds(&daily);
270        assert_eq!(p25, 5);
271        assert_eq!(p50, 5);
272        assert_eq!(p75, 5);
273    }
274
275    #[test]
276    fn test_stats_streaks() {
277        let today = Local::now().date_naive();
278        let daily: Vec<DailyActivity> = (0..7)
279            .map(|i| DailyActivity {
280                date: today - chrono::Duration::days(6 - i),
281                turns: if i < 3 { 0 } else { 5 }, // 4 active days ending at today
282                cost: 0.0,
283                sessions: if i < 3 { 0 } else { 1 },
284            })
285            .collect();
286
287        let stats = compute_stats(&daily, today);
288        assert_eq!(stats.active_days, 4);
289        assert_eq!(stats.current_streak, 4);
290        assert_eq!(stats.longest_streak, 4);
291        assert_eq!(stats.total_days, 7);
292    }
293
294    #[test]
295    fn test_stats_broken_streak() {
296        let today = Local::now().date_naive();
297        let daily: Vec<DailyActivity> = (0..7)
298            .map(|i| DailyActivity {
299                date: today - chrono::Duration::days(6 - i),
300                turns: if i == 4 { 0 } else { 3 }, // gap at day 4
301                cost: 0.0,
302                sessions: if i == 4 { 0 } else { 1 },
303            })
304            .collect();
305
306        let stats = compute_stats(&daily, today);
307        assert_eq!(stats.active_days, 6);
308        assert_eq!(stats.current_streak, 2); // last 2 days
309        assert_eq!(stats.longest_streak, 4); // first 4 days
310    }
311
312    #[test]
313    fn test_analyze_with_sessions() {
314        let calc = PricingCalculator::new();
315        let sessions = vec![make_session(
316            "s1",
317            vec![
318                make_turn("2026-03-20T10:00:00Z"),
319                make_turn("2026-03-20T11:00:00Z"),
320                make_turn("2026-03-21T09:00:00Z"),
321            ],
322        )];
323
324        let result = analyze_heatmap(&sessions, &calc, 30);
325        assert!(result.daily.len() <= 30);
326        assert!(result.stats.active_days >= 1);
327    }
328
329    #[test]
330    fn test_busiest_day() {
331        let today = Local::now().date_naive();
332        let daily = vec![
333            DailyActivity {
334                date: today - chrono::Duration::days(2),
335                turns: 3,
336                cost: 0.0,
337                sessions: 1,
338            },
339            DailyActivity {
340                date: today - chrono::Duration::days(1),
341                turns: 10,
342                cost: 0.0,
343                sessions: 2,
344            },
345            DailyActivity {
346                date: today,
347                turns: 1,
348                cost: 0.0,
349                sessions: 1,
350            },
351        ];
352
353        let stats = compute_stats(&daily, today);
354        assert_eq!(stats.busiest_day.unwrap().1, 10);
355    }
356}