1use std::collections::{BTreeSet, HashMap};
2
3use chrono::{Datelike, Local, NaiveDate, Timelike, Utc};
4use serde::Serialize;
5
6use crate::data::models::SessionData;
7use crate::pricing::calculator::PricingCalculator;
8
9use super::project::project_display_name;
10
11#[derive(Debug, Clone, Serialize)]
14pub enum DeveloperArchetype {
15 Architect,
16 Sprinter,
17 NightOwl,
18 Delegator,
19 Explorer,
20 Marathoner,
21}
22
23impl DeveloperArchetype {
24 pub fn label(&self) -> &'static str {
25 match self {
26 Self::Architect => "The Architect",
27 Self::Sprinter => "The Sprinter",
28 Self::NightOwl => "The Night Owl",
29 Self::Delegator => "The Delegator",
30 Self::Explorer => "The Explorer",
31 Self::Marathoner => "The Marathoner",
32 }
33 }
34
35 pub fn description(&self) -> &'static str {
36 match self {
37 Self::Architect => "You love orchestrating multi-agent teams for complex tasks.",
38 Self::Sprinter => {
39 "Short, intense bursts of productivity — you get in, get it done, and get out."
40 }
41 Self::NightOwl => {
42 "The best code is written after dark. Your peak hours are when the world sleeps."
43 }
44 Self::Delegator => {
45 "You trust your agents more than yourself. Maximum delegation, maximum output."
46 }
47 Self::Explorer => "A polyglot of projects — always trying something new.",
48 Self::Marathoner => {
49 "You settle in for the long haul. Deep work sessions are your superpower."
50 }
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize)]
58pub struct WrappedResult {
59 pub year: i32,
60
61 pub active_days: usize,
63 pub total_days: usize,
64 pub longest_streak: usize,
65 pub ghost_days: usize,
66
67 pub total_sessions: usize,
69 pub total_turns: usize,
70 pub total_agent_turns: usize,
71 pub total_output_tokens: u64,
72 pub total_input_tokens: u64,
73 pub total_cost: f64,
74
75 pub autonomy_ratio: f64,
77 pub avg_session_duration_min: f64,
78 pub avg_cost_per_session: f64,
79 pub output_ratio: f64,
80
81 pub peak_hour: usize,
83 pub peak_weekday: String,
84 pub hourly_distribution: [usize; 24],
85 pub weekday_distribution: [usize; 7],
86
87 pub top_projects: Vec<(String, f64)>,
89 pub top_tools: Vec<(String, usize)>,
90 pub most_expensive_session: Option<(String, f64, String)>,
91 pub longest_session: Option<(String, f64, String)>,
92
93 pub model_distribution: Vec<(String, usize)>,
95
96 pub archetype: DeveloperArchetype,
98
99 pub total_pr_count: usize,
101 pub total_speculation_time_saved_ms: f64,
102 pub total_collapse_count: usize,
103}
104
105pub fn analyze_wrapped(
108 sessions: &[SessionData],
109 calc: &PricingCalculator,
110 year: i32,
111) -> WrappedResult {
112 let year_sessions: Vec<&SessionData> = sessions
114 .iter()
115 .filter(|s| {
116 s.first_timestamp
117 .map(|t| t.with_timezone(&Local).year() == year)
118 .unwrap_or(false)
119 })
120 .collect();
121
122 let mut active_dates: BTreeSet<NaiveDate> = BTreeSet::new();
124
125 let mut total_turns: usize = 0;
127 let mut total_agent_turns: usize = 0;
128 let mut total_output_tokens: u64 = 0;
129 let mut total_input_tokens: u64 = 0;
130 let mut total_cost: f64 = 0.0;
131
132 let mut hourly_distribution = [0usize; 24];
134 let mut weekday_distribution = [0usize; 7]; let mut tool_counts: HashMap<String, usize> = HashMap::new();
138 let mut model_counts: HashMap<String, usize> = HashMap::new();
139
140 let mut project_costs: HashMap<String, f64> = HashMap::new();
142
143 let mut session_costs: Vec<(String, f64, String)> = Vec::new(); let mut session_durations: Vec<(String, f64, String)> = Vec::new(); let mut total_duration_min: f64 = 0.0;
147 let mut sessions_with_duration: usize = 0;
148
149 let mut total_user_prompts: usize = 0;
151 let mut total_pr_count: usize = 0;
152 let mut total_speculation_time_saved_ms: f64 = 0.0;
153 let mut total_collapse_count: usize = 0;
154 let mut unique_projects: BTreeSet<String> = BTreeSet::new();
155
156 for session in &year_sessions {
157 let project = session
158 .project
159 .as_deref()
160 .map(project_display_name)
161 .unwrap_or_else(|| "(unknown)".to_string());
162
163 unique_projects.insert(project.clone());
164
165 let duration_min = match (session.first_timestamp, session.last_timestamp) {
167 (Some(first), Some(last)) => {
168 let d = (last - first).num_seconds() as f64 / 60.0;
169 if d > 0.0 {
170 total_duration_min += d;
171 sessions_with_duration += 1;
172 }
173 d
174 }
175 _ => 0.0,
176 };
177
178 total_user_prompts += session.metadata.user_prompt_count;
180 total_pr_count += session.metadata.pr_links.len();
181 total_speculation_time_saved_ms += session.metadata.speculation_time_saved_ms;
182 total_collapse_count += session.metadata.collapse_commits.len();
183
184 let mut session_cost = 0.0f64;
185
186 for turn in session.all_responses() {
187 total_turns += 1;
188 if turn.is_agent {
189 total_agent_turns += 1;
190 }
191
192 let out = turn.usage.output_tokens.unwrap_or(0);
193 let inp = turn.usage.input_tokens.unwrap_or(0)
194 + turn.usage.cache_creation_input_tokens.unwrap_or(0)
195 + turn.usage.cache_read_input_tokens.unwrap_or(0);
196
197 total_output_tokens += out;
198 total_input_tokens += inp;
199
200 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
201 session_cost += cost.total;
202
203 let local_ts = turn.timestamp.with_timezone(&Local);
205 let hour = local_ts.hour() as usize;
206 hourly_distribution[hour] += 1;
207 let weekday = local_ts.weekday().num_days_from_monday() as usize;
208 weekday_distribution[weekday] += 1;
209
210 active_dates.insert(local_ts.date_naive());
212
213 for name in &turn.tool_names {
215 *tool_counts.entry(name.clone()).or_insert(0) += 1;
216 }
217
218 *model_counts.entry(turn.model.clone()).or_insert(0) += 1;
220 }
221
222 total_cost += session_cost;
223 *project_costs.entry(project.clone()).or_insert(0.0) += session_cost;
224 session_costs.push((session.session_id.clone(), session_cost, project.clone()));
225 session_durations.push((session.session_id.clone(), duration_min, project));
226 }
227
228 let now = Utc::now().with_timezone(&Local);
230 let total_days = if now.year() == year {
231 now.ordinal() as usize
232 } else if year < now.year() {
233 NaiveDate::from_ymd_opt(year, 12, 31)
235 .map(|d| d.ordinal() as usize)
236 .unwrap_or(365)
237 } else {
238 0
240 };
241
242 let active_days = active_dates.len();
243 let ghost_days = total_days.saturating_sub(active_days);
244
245 let longest_streak = compute_longest_streak(&active_dates);
247
248 let autonomy_ratio = if total_user_prompts > 0 {
250 total_turns as f64 / total_user_prompts as f64
251 } else {
252 0.0
253 };
254
255 let avg_session_duration_min = if sessions_with_duration > 0 {
256 total_duration_min / sessions_with_duration as f64
257 } else {
258 0.0
259 };
260
261 let avg_cost_per_session = if !year_sessions.is_empty() {
262 total_cost / year_sessions.len() as f64
263 } else {
264 0.0
265 };
266
267 let output_ratio = if total_input_tokens > 0 {
268 total_output_tokens as f64 / total_input_tokens as f64 * 100.0
269 } else {
270 0.0
271 };
272
273 let peak_hour = hourly_distribution
275 .iter()
276 .enumerate()
277 .max_by_key(|(_, &c)| c)
278 .map(|(h, _)| h)
279 .unwrap_or(0);
280
281 let weekday_names = [
282 "Monday",
283 "Tuesday",
284 "Wednesday",
285 "Thursday",
286 "Friday",
287 "Saturday",
288 "Sunday",
289 ];
290 let peak_weekday_idx = weekday_distribution
291 .iter()
292 .enumerate()
293 .max_by_key(|(_, &c)| c)
294 .map(|(d, _)| d)
295 .unwrap_or(0);
296 let peak_weekday = weekday_names[peak_weekday_idx].to_string();
297
298 let mut top_projects: Vec<(String, f64)> = project_costs.into_iter().collect();
300 top_projects.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
301 top_projects.truncate(5);
302
303 let mut top_tools: Vec<(String, usize)> = tool_counts.into_iter().collect();
305 top_tools.sort_by(|a, b| b.1.cmp(&a.1));
306 top_tools.truncate(5);
307
308 session_costs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
310 let most_expensive_session = session_costs.first().cloned();
311
312 session_durations.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
314 let longest_session = session_durations.first().cloned();
315
316 let mut model_distribution: Vec<(String, usize)> = model_counts.into_iter().collect();
318 model_distribution.sort_by(|a, b| b.1.cmp(&a.1));
319
320 let agent_ratio = if total_turns > 0 {
322 total_agent_turns as f64 / total_turns as f64
323 } else {
324 0.0
325 };
326
327 let night_turns: usize = hourly_distribution[22..].iter().sum::<usize>()
328 + hourly_distribution[..6].iter().sum::<usize>();
329 let night_ratio = if total_turns > 0 {
330 night_turns as f64 / total_turns as f64
331 } else {
332 0.0
333 };
334
335 let turns_per_session = if !year_sessions.is_empty() {
336 total_turns as f64 / year_sessions.len() as f64
337 } else {
338 0.0
339 };
340
341 let archetype = classify_archetype(
342 agent_ratio,
343 night_ratio,
344 avg_session_duration_min,
345 turns_per_session,
346 unique_projects.len(),
347 );
348
349 WrappedResult {
350 year,
351 active_days,
352 total_days,
353 longest_streak,
354 ghost_days,
355 total_sessions: year_sessions.len(),
356 total_turns,
357 total_agent_turns,
358 total_output_tokens,
359 total_input_tokens,
360 total_cost,
361 autonomy_ratio,
362 avg_session_duration_min,
363 avg_cost_per_session,
364 output_ratio,
365 peak_hour,
366 peak_weekday,
367 hourly_distribution,
368 weekday_distribution,
369 top_projects,
370 top_tools,
371 most_expensive_session,
372 longest_session,
373 model_distribution,
374 archetype,
375 total_pr_count,
376 total_speculation_time_saved_ms,
377 total_collapse_count,
378 }
379}
380
381fn compute_longest_streak(dates: &BTreeSet<NaiveDate>) -> usize {
384 if dates.is_empty() {
385 return 0;
386 }
387
388 let sorted: Vec<NaiveDate> = dates.iter().copied().collect();
389 let mut longest = 1usize;
390 let mut current = 1usize;
391
392 for window in sorted.windows(2) {
393 let diff = window[1].signed_duration_since(window[0]).num_days();
394 if diff == 1 {
395 current += 1;
396 if current > longest {
397 longest = current;
398 }
399 } else {
400 current = 1;
401 }
402 }
403
404 longest
405}
406
407fn classify_archetype(
408 agent_ratio: f64,
409 night_ratio: f64,
410 avg_session_min: f64,
411 turns_per_session: f64,
412 unique_project_count: usize,
413) -> DeveloperArchetype {
414 if agent_ratio > 0.5 {
416 return DeveloperArchetype::Delegator;
417 }
418 if night_ratio > 0.5 {
420 return DeveloperArchetype::NightOwl;
421 }
422 if avg_session_min > 120.0 {
424 return DeveloperArchetype::Marathoner;
425 }
426 if agent_ratio > 0.4 && avg_session_min > 60.0 {
428 return DeveloperArchetype::Architect;
429 }
430 if avg_session_min < 30.0 && turns_per_session > 10.0 {
432 return DeveloperArchetype::Sprinter;
433 }
434 if unique_project_count > 10 {
436 return DeveloperArchetype::Explorer;
437 }
438 DeveloperArchetype::Architect
440}
441
442#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn test_compute_longest_streak_empty() {
450 let dates = BTreeSet::new();
451 assert_eq!(compute_longest_streak(&dates), 0);
452 }
453
454 #[test]
455 fn test_compute_longest_streak_single() {
456 let mut dates = BTreeSet::new();
457 dates.insert(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
458 assert_eq!(compute_longest_streak(&dates), 1);
459 }
460
461 #[test]
462 fn test_compute_longest_streak_consecutive() {
463 let mut dates = BTreeSet::new();
464 dates.insert(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
465 dates.insert(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap());
466 dates.insert(NaiveDate::from_ymd_opt(2026, 1, 3).unwrap());
467 dates.insert(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()); dates.insert(NaiveDate::from_ymd_opt(2026, 1, 6).unwrap());
469 assert_eq!(compute_longest_streak(&dates), 3);
470 }
471
472 #[test]
473 fn test_compute_longest_streak_all_consecutive() {
474 let mut dates = BTreeSet::new();
475 for d in 1..=10 {
476 dates.insert(NaiveDate::from_ymd_opt(2026, 3, d).unwrap());
477 }
478 assert_eq!(compute_longest_streak(&dates), 10);
479 }
480
481 #[test]
482 fn test_classify_delegator() {
483 let arch = classify_archetype(0.6, 0.1, 45.0, 20.0, 3);
484 assert!(matches!(arch, DeveloperArchetype::Delegator));
485 }
486
487 #[test]
488 fn test_classify_night_owl() {
489 let arch = classify_archetype(0.3, 0.6, 45.0, 20.0, 3);
490 assert!(matches!(arch, DeveloperArchetype::NightOwl));
491 }
492
493 #[test]
494 fn test_classify_marathoner() {
495 let arch = classify_archetype(0.3, 0.1, 150.0, 20.0, 3);
496 assert!(matches!(arch, DeveloperArchetype::Marathoner));
497 }
498
499 #[test]
500 fn test_classify_architect() {
501 let arch = classify_archetype(0.45, 0.1, 90.0, 20.0, 3);
502 assert!(matches!(arch, DeveloperArchetype::Architect));
503 }
504
505 #[test]
506 fn test_classify_sprinter() {
507 let arch = classify_archetype(0.1, 0.1, 15.0, 15.0, 3);
508 assert!(matches!(arch, DeveloperArchetype::Sprinter));
509 }
510
511 #[test]
512 fn test_classify_explorer() {
513 let arch = classify_archetype(0.1, 0.1, 45.0, 8.0, 15);
514 assert!(matches!(arch, DeveloperArchetype::Explorer));
515 }
516
517 #[test]
518 fn test_classify_default_architect() {
519 let arch = classify_archetype(0.1, 0.1, 45.0, 5.0, 3);
520 assert!(matches!(arch, DeveloperArchetype::Architect));
521 }
522}