ai_agent/utils/
heatmap.rs1use chrono::Datelike;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct HeatmapOptions {
12 pub terminal_width: Option<usize>,
14 pub show_month_labels: Option<bool>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DailyActivity {
21 pub date: String,
23 pub message_count: usize,
25}
26
27#[derive(Debug, Clone)]
29struct Percentiles {
30 p25: usize,
31 p50: usize,
32 p75: usize,
33}
34
35fn 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
57fn 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
76fn get_heatmap_char(intensity: usize) -> String {
78 let orange = "\x1b[38;2;218;119;86m"; 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
92fn to_date_string(date: &chrono::NaiveDate) -> String {
94 date.format("%Y-%m-%d").to_string()
95}
96
97pub 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 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 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 let percentiles = calculate_percentiles(daily_activity);
123
124 let today = chrono::Local::now().date_naive();
126
127 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 let start_date = current_week_start - chrono::Duration::weeks((width - 1) as i64);
134
135 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 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(¤t_date);
152 let activity = activity_map.get(&date_str);
153
154 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 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 let mut lines: Vec<String> = vec![];
174
175 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 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 let day_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
202
203 for day in 0..7 {
205 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 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}