1use std::collections::HashMap;
2
3use chrono::{Local, NaiveDate};
4
5use crate::data::models::SessionData;
6use crate::pricing::calculator::PricingCalculator;
7
8#[derive(Debug)]
11pub struct HeatmapResult {
12 pub daily: Vec<DailyActivity>,
13 pub start_date: NaiveDate,
15 pub end_date: NaiveDate,
17 pub thresholds: (usize, usize, usize),
19 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
40pub 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 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 let mut day_map: HashMap<NaiveDate, (usize, f64, usize)> = HashMap::new();
63
64 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 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 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 let thresholds = compute_thresholds(&daily);
101
102 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 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 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 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 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#[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 }, 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 }, 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); assert_eq!(stats.longest_streak, 4); }
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 #[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 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 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}