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 }
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 }, 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 }, 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); assert_eq!(stats.longest_streak, 4); }
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}