use chrono::Datelike;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeatmapOptions {
pub terminal_width: Option<usize>,
pub show_month_labels: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyActivity {
pub date: String,
pub message_count: usize,
}
#[derive(Debug, Clone)]
struct Percentiles {
p25: usize,
p50: usize,
p75: usize,
}
fn calculate_percentiles(daily_activity: &[DailyActivity]) -> Option<Percentiles> {
let mut counts: Vec<usize> = daily_activity
.iter()
.map(|a| a.message_count)
.filter(|&c| c > 0)
.collect();
if counts.is_empty() {
return None;
}
counts.sort();
let len = counts.len();
Some(Percentiles {
p25: counts[len / 4],
p50: counts[len / 2],
p75: counts[(len * 3) / 4],
})
}
fn get_intensity(message_count: usize, percentiles: &Option<Percentiles>) -> usize {
if message_count == 0 || percentiles.is_none() {
return 0;
}
let p = percentiles.as_ref().unwrap();
if message_count >= p.p75 {
return 4;
}
if message_count >= p.p50 {
return 3;
}
if message_count >= p.p25 {
return 2;
}
return 1;
}
fn get_heatmap_char(intensity: usize) -> String {
let orange = "\x1b[38;2;218;119;86m"; let reset = "\x1b[0m";
match intensity {
0 => format!("{}·{}", orange, reset),
1 => format!("{}░{}", orange, reset),
2 => format!("{}▒{}", orange, reset),
3 => format!("{}▓{}", orange, reset),
4 => format!("{}█{}", orange, reset),
_ => format!("{}·{}", orange, reset),
}
}
fn to_date_string(date: &chrono::NaiveDate) -> String {
date.format("%Y-%m-%d").to_string()
}
pub fn generate_heatmap(daily_activity: &[DailyActivity], options: HeatmapOptions) -> String {
let terminal_width = options.terminal_width.unwrap_or(80);
let show_month_labels = options.show_month_labels.unwrap_or(true);
let day_label_width = 4;
let available_width = terminal_width - day_label_width;
let width = 52.min(10.max(available_width));
let mut activity_map = std::collections::HashMap::new();
for activity in daily_activity {
activity_map.insert(activity.date.clone(), activity);
}
let percentiles = calculate_percentiles(daily_activity);
let today = chrono::Local::now().date_naive();
let weekday = today.weekday();
let days_since_sunday = weekday.num_days_from_sunday() as i64;
let current_week_start = today - chrono::Duration::days(days_since_sunday);
let start_date = current_week_start - chrono::Duration::weeks((width - 1) as i64);
let mut grid: Vec<Vec<String>> = vec![vec![String::new(); width]; 7];
let mut month_starts: Vec<(u32, usize)> = vec![];
let mut last_month: Option<u32> = None;
let mut current_date = start_date;
for week in 0..width {
for day in 0..7 {
if current_date > today {
grid[day][week] = " ".to_string();
current_date = current_date + chrono::Duration::days(1);
continue;
}
let date_str = to_date_string(¤t_date);
let activity = activity_map.get(&date_str);
if day == 0 {
let month = current_date.month();
if last_month != Some(month) {
month_starts.push((month, week));
last_month = Some(month);
}
}
let intensity =
get_intensity(activity.map(|a| a.message_count).unwrap_or(0), &percentiles);
grid[day][week] = get_heatmap_char(intensity);
current_date = current_date + chrono::Duration::days(1);
}
}
let mut lines: Vec<String> = vec![];
if show_month_labels {
let month_names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let unique_months: Vec<u32> = month_starts.iter().map(|(m, _)| *m).collect();
let label_width = width / unique_months.len().max(1);
let month_labels: String = unique_months
.iter()
.filter_map(|&m| month_names.get((m - 1) as usize).copied())
.map(|s| {
let mut result = s.to_string();
while result.len() < label_width {
result.push(' ');
}
result
})
.collect();
lines.push(format!(" {}", month_labels));
}
let day_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for day in 0..7 {
let label = if [1, 3, 5].contains(&day) {
let mut s = day_labels[day].to_string();
while s.len() < 3 {
s.push(' ');
}
s
} else {
" ".to_string()
};
let row = format!("{} {}", label, grid[day].join(""));
lines.push(row);
}
lines.push(String::new());
let orange = "\x1b[38;2;218;119;86m";
let reset = "\x1b[0m";
lines.push(format!(
" Less {}░{} {}▒{} {}▓{} {}█{} More",
orange, reset, orange, reset, orange, reset, orange, reset
));
lines.join("\n")
}