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            attribution_plugin: None,
230            attribution_skill: None,
231        }
232    }
233
234    use chrono::DateTime;
235
236    fn make_session(id: &str, turns: Vec<ValidatedTurn>) -> SessionData {
237        let first = turns.first().map(|t| t.timestamp);
238        let last = turns.last().map(|t| t.timestamp);
239        SessionData {
240            session_id: id.to_string(),
241            project: Some("test-project".to_string()),
242            turns,
243            subagents: vec![],
244            plugins: vec![],
245            skills: vec![],
246            hooks: vec![],
247            first_timestamp: first,
248            last_timestamp: last,
249            version: None,
250            quality: DataQuality::default(),
251            metadata: SessionMetadata::default(),
252            is_orphan: false,
253        }
254    }
255
256    #[test]
257    fn test_thresholds_empty() {
258        let daily = vec![];
259        let (p25, p50, p75) = compute_thresholds(&daily);
260        assert!(p25 >= 1);
261        assert!(p50 >= p25);
262        assert!(p75 >= p50);
263    }
264
265    #[test]
266    fn test_thresholds_uniform() {
267        let daily: Vec<DailyActivity> = (0..10)
268            .map(|i| DailyActivity {
269                date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i),
270                turns: 5,
271                cost: 0.0,
272                sessions: 1,
273            })
274            .collect();
275        let (p25, p50, p75) = compute_thresholds(&daily);
276        assert_eq!(p25, 5);
277        assert_eq!(p50, 5);
278        assert_eq!(p75, 5);
279    }
280
281    #[test]
282    fn test_stats_streaks() {
283        let today = Local::now().date_naive();
284        let daily: Vec<DailyActivity> = (0..7)
285            .map(|i| DailyActivity {
286                date: today - chrono::Duration::days(6 - i),
287                turns: if i < 3 { 0 } else { 5 }, // 4 active days ending at today
288                cost: 0.0,
289                sessions: if i < 3 { 0 } else { 1 },
290            })
291            .collect();
292
293        let stats = compute_stats(&daily, today);
294        assert_eq!(stats.active_days, 4);
295        assert_eq!(stats.current_streak, 4);
296        assert_eq!(stats.longest_streak, 4);
297        assert_eq!(stats.total_days, 7);
298    }
299
300    #[test]
301    fn test_stats_broken_streak() {
302        let today = Local::now().date_naive();
303        let daily: Vec<DailyActivity> = (0..7)
304            .map(|i| DailyActivity {
305                date: today - chrono::Duration::days(6 - i),
306                turns: if i == 4 { 0 } else { 3 }, // gap at day 4
307                cost: 0.0,
308                sessions: if i == 4 { 0 } else { 1 },
309            })
310            .collect();
311
312        let stats = compute_stats(&daily, today);
313        assert_eq!(stats.active_days, 6);
314        assert_eq!(stats.current_streak, 2); // last 2 days
315        assert_eq!(stats.longest_streak, 4); // first 4 days
316    }
317
318    #[test]
319    fn test_analyze_with_sessions() {
320        let calc = PricingCalculator::new();
321        let now = Utc::now();
322        let two_days_ago = (now - chrono::Duration::days(2)).to_rfc3339();
323        let one_day_ago = (now - chrono::Duration::days(1)).to_rfc3339();
324        let sessions = vec![make_session(
325            "s1",
326            vec![
327                make_turn(&two_days_ago),
328                make_turn(&two_days_ago),
329                make_turn(&one_day_ago),
330            ],
331        )];
332
333        let result = analyze_heatmap(&sessions, &calc, 30);
334        assert!(result.daily.len() <= 30);
335        assert!(result.stats.active_days >= 1);
336    }
337
338    #[test]
339    fn test_busiest_day() {
340        let today = Local::now().date_naive();
341        let daily = vec![
342            DailyActivity {
343                date: today - chrono::Duration::days(2),
344                turns: 3,
345                cost: 0.0,
346                sessions: 1,
347            },
348            DailyActivity {
349                date: today - chrono::Duration::days(1),
350                turns: 10,
351                cost: 0.0,
352                sessions: 2,
353            },
354            DailyActivity {
355                date: today,
356                turns: 1,
357                cost: 0.0,
358                sessions: 1,
359            },
360        ];
361
362        let stats = compute_stats(&daily, today);
363        assert_eq!(stats.busiest_day.unwrap().1, 10);
364    }
365
366    /// A session whose turns span two local days must contribute its turns
367    /// to *both* days (per turn), not lump everything onto its start day.
368    /// This is the regression test for the multi-day attribution bug.
369    #[test]
370    fn analyze_heatmap_splits_multi_day_session_turns_per_day() {
371        use chrono::TimeZone;
372
373        let calc = PricingCalculator::new();
374        let today = Local::now().date_naive();
375        let day_a = today - chrono::Duration::days(2);
376        let day_b = today - chrono::Duration::days(1);
377
378        // 12:00 local on each of day_a and day_b, expressed in UTC.
379        let ts_a: DateTime<Utc> = Local
380            .from_local_datetime(&day_a.and_hms_opt(12, 0, 0).unwrap())
381            .single()
382            .unwrap()
383            .with_timezone(&Utc);
384        let ts_b: DateTime<Utc> = Local
385            .from_local_datetime(&day_b.and_hms_opt(12, 0, 0).unwrap())
386            .single()
387            .unwrap()
388            .with_timezone(&Utc);
389
390        // Session has 1 turn on day_a, 3 turns on day_b. Busiest day = day_b.
391        let session = make_session(
392            "s1",
393            vec![
394                make_turn(&ts_a.to_rfc3339()),
395                make_turn(&ts_b.to_rfc3339()),
396                make_turn(&ts_b.to_rfc3339()),
397                make_turn(&ts_b.to_rfc3339()),
398            ],
399        );
400
401        let result = analyze_heatmap(&[session], &calc, 7);
402        let entry_a = result.daily.iter().find(|d| d.date == day_a).unwrap();
403        let entry_b = result.daily.iter().find(|d| d.date == day_b).unwrap();
404        assert_eq!(entry_a.turns, 1, "day_a must have exactly 1 turn");
405        assert_eq!(entry_b.turns, 3, "day_b must have exactly 3 turns");
406        assert_eq!(result.stats.busiest_day.unwrap().0, day_b);
407        assert_eq!(result.stats.busiest_day.unwrap().1, 3);
408    }
409}