Skip to main content

ai_agent/utils/
heatmap.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/heatmap.ts
2//! Heatmap generation utilities
3//!
4//! Generates a GitHub-style activity heatmap for the terminal
5
6use chrono::Datelike;
7use serde::{Deserialize, Serialize};
8
9/// Heatmap options
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct HeatmapOptions {
12    /// Terminal width in characters
13    pub terminal_width: Option<usize>,
14    /// Whether to show month labels
15    pub show_month_labels: Option<bool>,
16}
17
18/// Daily activity data
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DailyActivity {
21    /// Date string (YYYY-MM-DD)
22    pub date: String,
23    /// Number of messages
24    pub message_count: usize,
25}
26
27/// Percentiles for intensity calculation
28#[derive(Debug, Clone)]
29struct Percentiles {
30    p25: usize,
31    p50: usize,
32    p75: usize,
33}
34
35/// Pre-calculates percentiles from activity data for use in intensity calculations
36fn calculate_percentiles(daily_activity: &[DailyActivity]) -> Option<Percentiles> {
37    let mut counts: Vec<usize> = daily_activity
38        .iter()
39        .map(|a| a.message_count)
40        .filter(|&c| c > 0)
41        .collect();
42
43    if counts.is_empty() {
44        return None;
45    }
46
47    counts.sort();
48    let len = counts.len();
49
50    Some(Percentiles {
51        p25: counts[len / 4],
52        p50: counts[len / 2],
53        p75: counts[(len * 3) / 4],
54    })
55}
56
57/// Get intensity level (0-4) based on message count and percentiles
58fn get_intensity(message_count: usize, percentiles: &Option<Percentiles>) -> usize {
59    if message_count == 0 || percentiles.is_none() {
60        return 0;
61    }
62
63    let p = percentiles.as_ref().unwrap();
64    if message_count >= p.p75 {
65        return 4;
66    }
67    if message_count >= p.p50 {
68        return 3;
69    }
70    if message_count >= p.p25 {
71        return 2;
72    }
73    return 1;
74}
75
76/// Get heatmap character for intensity level
77fn get_heatmap_char(intensity: usize) -> String {
78    // Using Unicode block characters with orange color escape code
79    let orange = "\x1b[38;2;218;119;86m"; // #da7756
80    let reset = "\x1b[0m";
81
82    match intensity {
83        0 => format!("{}·{}", orange, reset),
84        1 => format!("{}░{}", orange, reset),
85        2 => format!("{}▒{}", orange, reset),
86        3 => format!("{}▓{}", orange, reset),
87        4 => format!("{}█{}", orange, reset),
88        _ => format!("{}·{}", orange, reset),
89    }
90}
91
92/// Converts a DateTime to a date string (YYYY-MM-DD)
93fn to_date_string(date: &chrono::NaiveDate) -> String {
94    date.format("%Y-%m-%d").to_string()
95}
96
97/// Generates a GitHub-style activity heatmap for the terminal
98///
99/// # Arguments
100/// * `daily_activity` - Array of daily activity data
101/// * `options` - Heatmap options
102///
103/// # Returns
104/// String representation of the heatmap
105pub fn generate_heatmap(daily_activity: &[DailyActivity], options: HeatmapOptions) -> String {
106    let terminal_width = options.terminal_width.unwrap_or(80);
107    let show_month_labels = options.show_month_labels.unwrap_or(true);
108
109    // Day labels take 4 characters ("Mon "), calculate weeks that fit
110    // Cap at 52 weeks (1 year) to match GitHub style
111    let day_label_width = 4;
112    let available_width = terminal_width - day_label_width;
113    let width = 52.min(10.max(available_width));
114
115    // Build activity map by date
116    let mut activity_map = std::collections::HashMap::new();
117    for activity in daily_activity {
118        activity_map.insert(activity.date.clone(), activity);
119    }
120
121    // Pre-calculate percentiles once for all intensity lookups
122    let percentiles = calculate_percentiles(daily_activity);
123
124    // Calculate date range - end at today, go back N weeks
125    let today = chrono::Local::now().date_naive();
126
127    // Find the Sunday of the current week (start of the week containing today)
128    let weekday = today.weekday();
129    let days_since_sunday = weekday.num_days_from_sunday() as i64;
130    let current_week_start = today - chrono::Duration::days(days_since_sunday);
131
132    // Go back (width - 1) weeks from the current week start
133    let start_date = current_week_start - chrono::Duration::weeks((width - 1) as i64);
134
135    // Generate grid (7 rows for days of week, width columns for weeks)
136    // Also track which week each month starts for labels
137    let mut grid: Vec<Vec<String>> = vec![vec![String::new(); width]; 7];
138    let mut month_starts: Vec<(u32, usize)> = vec![];
139    let mut last_month: Option<u32> = None;
140
141    let mut current_date = start_date;
142    for week in 0..width {
143        for day in 0..7 {
144            // Don't show future dates
145            if current_date > today {
146                grid[day][week] = " ".to_string();
147                current_date = current_date + chrono::Duration::days(1);
148                continue;
149            }
150
151            let date_str = to_date_string(&current_date);
152            let activity = activity_map.get(&date_str);
153
154            // Track month changes (on day 0 = Sunday of each week)
155            if day == 0 {
156                let month = current_date.month();
157                if last_month != Some(month) {
158                    month_starts.push((month, week));
159                    last_month = Some(month);
160                }
161            }
162
163            // Determine intensity level based on message count
164            let intensity =
165                get_intensity(activity.map(|a| a.message_count).unwrap_or(0), &percentiles);
166            grid[day][week] = get_heatmap_char(intensity);
167
168            current_date = current_date + chrono::Duration::days(1);
169        }
170    }
171
172    // Build output
173    let mut lines: Vec<String> = vec![];
174
175    // Month labels - evenly spaced across the grid
176    if show_month_labels {
177        let month_names = [
178            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
179        ];
180
181        let unique_months: Vec<u32> = month_starts.iter().map(|(m, _)| *m).collect();
182        let label_width = width / unique_months.len().max(1);
183
184        let month_labels: String = unique_months
185            .iter()
186            .filter_map(|&m| month_names.get((m - 1) as usize).copied())
187            .map(|s| {
188                // Pad the string to label_width
189                let mut result = s.to_string();
190                while result.len() < label_width {
191                    result.push(' ');
192                }
193                result
194            })
195            .collect();
196
197        lines.push(format!("    {}", month_labels));
198    }
199
200    // Day labels
201    let day_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
202
203    // Grid
204    for day in 0..7 {
205        // Only show labels for Mon, Wed, Fri
206        let label = if [1, 3, 5].contains(&day) {
207            let mut s = day_labels[day].to_string();
208            while s.len() < 3 {
209                s.push(' ');
210            }
211            s
212        } else {
213            "   ".to_string()
214        };
215        let row = format!("{} {}", label, grid[day].join(""));
216        lines.push(row);
217    }
218
219    // Legend
220    lines.push(String::new());
221    let orange = "\x1b[38;2;218;119;86m";
222    let reset = "\x1b[0m";
223    lines.push(format!(
224        "    Less {}░{} {}▒{} {}▓{} {}█{} More",
225        orange, reset, orange, reset, orange, reset, orange, reset
226    ));
227
228    lines.join("\n")
229}