Skip to main content

cc_token_usage/analysis/
wrapped.rs

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// ─── Developer Archetype ────────────────────────────────────────────────────
12
13#[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// ─── Result ─────────────────────────────────────────────────────────────────
56
57#[derive(Debug, Clone, Serialize)]
58pub struct WrappedResult {
59    pub year: i32,
60
61    // Activity
62    pub active_days: usize,
63    pub total_days: usize,
64    pub longest_streak: usize,
65    pub ghost_days: usize,
66
67    // Volume
68    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    // Efficiency
76    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    // Peak patterns
82    pub peak_hour: usize,
83    pub peak_weekday: String,
84    pub hourly_distribution: [usize; 24],
85    pub weekday_distribution: [usize; 7],
86
87    // Top items
88    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    // Models
94    pub model_distribution: Vec<(String, usize)>,
95
96    // Developer archetype
97    pub archetype: DeveloperArchetype,
98
99    // Metadata
100    pub total_pr_count: usize,
101    pub total_speculation_time_saved_ms: f64,
102    pub total_collapse_count: usize,
103}
104
105// ─── Analysis ───────────────────────────────────────────────────────────────
106
107pub fn analyze_wrapped(
108    sessions: &[SessionData],
109    calc: &PricingCalculator,
110    year: i32,
111) -> WrappedResult {
112    // Filter sessions to the specified year
113    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    // ── Activity dates ──────────────────────────────────────────────────────
123    let mut active_dates: BTreeSet<NaiveDate> = BTreeSet::new();
124
125    // ── Volume accumulators ─────────────────────────────────────────────────
126    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    // ── Distribution accumulators ───────────────────────────────────────────
133    let mut hourly_distribution = [0usize; 24];
134    let mut weekday_distribution = [0usize; 7]; // 0=Mon..6=Sun
135
136    // ── Tool & model accumulators ───────────────────────────────────────────
137    let mut tool_counts: HashMap<String, usize> = HashMap::new();
138    let mut model_counts: HashMap<String, usize> = HashMap::new();
139
140    // ── Project cost accumulator ────────────────────────────────────────────
141    let mut project_costs: HashMap<String, f64> = HashMap::new();
142
143    // ── Session-level tracking ──────────────────────────────────────────────
144    let mut session_costs: Vec<(String, f64, String)> = Vec::new(); // (id, cost, project)
145    let mut session_durations: Vec<(String, f64, String)> = Vec::new(); // (id, min, project)
146    let mut total_duration_min: f64 = 0.0;
147    let mut sessions_with_duration: usize = 0;
148
149    // ── Metadata accumulators ───────────────────────────────────────────────
150    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        // Session duration
166        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        // Metadata
179        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            // Hourly/weekday distribution (local time)
204            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 date
211            active_dates.insert(local_ts.date_naive());
212
213            // Tools
214            for name in &turn.tool_names {
215                *tool_counts.entry(name.clone()).or_insert(0) += 1;
216            }
217
218            // Models
219            *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    // ── Compute total_days ──────────────────────────────────────────────────
229    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        // Full year
234        NaiveDate::from_ymd_opt(year, 12, 31)
235            .map(|d| d.ordinal() as usize)
236            .unwrap_or(365)
237    } else {
238        // Future year — shouldn't happen, but handle gracefully
239        0
240    };
241
242    let active_days = active_dates.len();
243    let ghost_days = total_days.saturating_sub(active_days);
244
245    // ── Longest streak ──────────────────────────────────────────────────────
246    let longest_streak = compute_longest_streak(&active_dates);
247
248    // ── Efficiency ──────────────────────────────────────────────────────────
249    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    // ── Peak patterns ───────────────────────────────────────────────────────
274    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    // ── Top projects (by cost, top 5) ───────────────────────────────────────
299    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    // ── Top tools (by count, top 5) ─────────────────────────────────────────
304    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    // ── Most expensive session ──────────────────────────────────────────────
309    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    // ── Longest session ─────────────────────────────────────────────────────
313    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    // ── Model distribution ──────────────────────────────────────────────────
317    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    // ── Archetype classification ────────────────────────────────────────────
321    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
381// ─── Helpers ────────────────────────────────────────────────────────────────
382
383fn 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    // 1. Delegator: agent turns > 50% of total
415    if agent_ratio > 0.5 {
416        return DeveloperArchetype::Delegator;
417    }
418    // 2. NightOwl: >50% turns in 22:00-06:00
419    if night_ratio > 0.5 {
420        return DeveloperArchetype::NightOwl;
421    }
422    // 3. Marathoner: avg session > 120 min
423    if avg_session_min > 120.0 {
424        return DeveloperArchetype::Marathoner;
425    }
426    // 4. Architect: high agent + long sessions
427    if agent_ratio > 0.4 && avg_session_min > 60.0 {
428        return DeveloperArchetype::Architect;
429    }
430    // 5. Sprinter: short sessions with high turn density
431    if avg_session_min < 30.0 && turns_per_session > 10.0 {
432        return DeveloperArchetype::Sprinter;
433    }
434    // 6. Explorer: many projects
435    if unique_project_count > 10 {
436        return DeveloperArchetype::Explorer;
437    }
438    // 7. Default
439    DeveloperArchetype::Architect
440}
441
442// ─── Tests ──────────────────────────────────────────────────────────────────
443
444#[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()); // gap
468        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}