gitfetch_rs/display/
graph.rs

1use super::colors::get_ansi_color;
2use crate::config::ColorConfig;
3use chrono::{Datelike, NaiveDate};
4use serde_json::Value;
5
6pub struct ContributionGraph {
7  weeks: Vec<Week>,
8}
9
10#[derive(Clone)]
11pub struct Week {
12  pub contribution_days: Vec<Day>,
13}
14
15#[derive(Clone)]
16pub struct Day {
17  pub contribution_count: u32,
18  #[allow(dead_code)]
19  pub date: String,
20}
21
22impl ContributionGraph {
23  pub fn from_json(data: &Value) -> Self {
24    let weeks = data
25      .as_array()
26      .map(|arr| {
27        arr
28          .iter()
29          .map(|week| Week {
30            contribution_days: week["contributionDays"]
31              .as_array()
32              .map(|days| {
33                days
34                  .iter()
35                  .map(|day| Day {
36                    contribution_count: day["contributionCount"].as_u64().unwrap_or(0) as u32,
37                    date: day["date"].as_str().unwrap_or("").to_string(),
38                  })
39                  .collect()
40              })
41              .unwrap_or_default(),
42          })
43          .collect()
44      })
45      .unwrap_or_default();
46
47    Self { weeks }
48  }
49
50  pub fn from_grid(grid: Vec<Vec<u8>>) -> Self {
51    // grid is 7 rows x N columns
52    if grid.is_empty() || grid[0].is_empty() {
53      return Self { weeks: Vec::new() };
54    }
55
56    let num_columns = grid[0].len();
57    let mut weeks = Vec::new();
58
59    for col_idx in 0..num_columns {
60      let mut week_days = Vec::new();
61      for row_idx in 0..7 {
62        let intensity = if row_idx < grid.len() && col_idx < grid[row_idx].len() {
63          grid[row_idx][col_idx]
64        } else {
65          0
66        };
67
68        week_days.push(Day {
69          contribution_count: intensity as u32,
70          date: format!("2023-01-{:02}", col_idx + 1),
71        });
72      }
73
74      weeks.push(Week {
75        contribution_days: week_days,
76      });
77    }
78
79    Self { weeks }
80  }
81
82  pub fn render(
83    &self,
84    width: Option<usize>,
85    height: Option<usize>,
86    custom_box: &str,
87    colors: &ColorConfig,
88    show_date: bool,
89    spaced: bool,
90  ) -> Vec<String> {
91    let mut lines = Vec::new();
92
93    // Use specified width or default to 52 weeks
94    let num_weeks = width.unwrap_or(52);
95    let recent_weeks = self.get_recent_weeks(num_weeks);
96
97    if show_date {
98      let month_line = self.build_month_line(&recent_weeks);
99      lines.push(month_line);
100    }
101
102    // Use specified height or default to 7 days (full week)
103    let num_days = height.unwrap_or(7).min(7);
104
105    for day_idx in 0..num_days {
106      let mut row = String::from("    ");
107      for week in &recent_weeks {
108        if let Some(day) = week.contribution_days.get(day_idx) {
109          let block = if spaced {
110            self.get_contribution_block_spaced(day.contribution_count, custom_box, colors)
111          } else {
112            self.get_contribution_block(day.contribution_count, colors)
113          };
114          row.push_str(&block);
115        }
116      }
117      row.push_str("\x1b[0m");
118      lines.push(row);
119    }
120
121    lines
122  }
123
124  fn get_contribution_block(&self, count: u32, colors: &ColorConfig) -> String {
125    let color = match count {
126      0 => &colors.level_0,
127      1..=2 => &colors.level_1,
128      3..=6 => &colors.level_2,
129      7..=12 => &colors.level_3,
130      _ => &colors.level_4,
131    };
132
133    // Not-spaced mode: use background color for filled square (2 spaces)
134    let bg_color = get_ansi_color(color).unwrap_or_default();
135    let bg_ansi = if !bg_color.is_empty() && bg_color.starts_with("\x1b[38;2;") {
136      // Convert foreground (38) to background (48)
137      bg_color.replace("\x1b[38;2;", "\x1b[48;2;")
138    } else {
139      bg_color
140    };
141    format!("{}  \x1b[0m", bg_ansi)
142  }
143
144  fn get_contribution_block_spaced(
145    &self,
146    count: u32,
147    custom_box: &str,
148    colors: &ColorConfig,
149  ) -> String {
150    let color = match count {
151      0 => &colors.level_0,
152      1..=2 => &colors.level_1,
153      3..=6 => &colors.level_2,
154      7..=12 => &colors.level_3,
155      _ => &colors.level_4,
156    };
157
158    // Spaced mode: use custom box character with foreground color + space
159    // Do NOT include reset code here - it will be added at end of line
160    let ansi_color = get_ansi_color(color).unwrap_or_default();
161    format!("{}{} ", ansi_color, custom_box)
162  }
163
164  fn get_recent_weeks(&self, limit: usize) -> Vec<Week> {
165    if self.weeks.len() <= limit {
166      self.weeks.clone()
167    } else {
168      self.weeks[self.weeks.len() - limit..].to_vec()
169    }
170  }
171
172  fn build_month_line(&self, weeks: &[Week]) -> String {
173    if weeks.is_empty() {
174      return String::new();
175    }
176
177    let months = [
178      "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
179    ];
180
181    let mut month_line = String::new();
182
183    for (idx, week) in weeks.iter().enumerate() {
184      if week.contribution_days.is_empty() {
185        continue;
186      }
187
188      let first_day = &week.contribution_days[0];
189      if let Ok(date) = NaiveDate::parse_from_str(&first_day.date, "%Y-%m-%d") {
190        let current_month = date.month() as usize;
191
192        if idx == 0 {
193          month_line.push_str(months[current_month - 1]);
194        } else {
195          if let Some(prev_week) = weeks.get(idx - 1) {
196            if !prev_week.contribution_days.is_empty() {
197              if let Ok(prev_date) =
198                NaiveDate::parse_from_str(&prev_week.contribution_days[0].date, "%Y-%m-%d")
199              {
200                let prev_month = prev_date.month() as usize;
201                if current_month != prev_month {
202                  let target_width = (idx + 1) * 2;
203                  let current_width = month_line.len();
204                  let month_name = months[current_month - 1];
205                  // Prevent underflow: ensure target_width > current_width + month_name.len()
206                  if target_width > current_width + month_name.len() {
207                    let needed_space = target_width - current_width - month_name.len();
208                    month_line.push_str(&" ".repeat(needed_space));
209                    month_line.push_str(month_name);
210                  } else {
211                    month_line.push(' ');
212                    month_line.push_str(month_name);
213                  }
214                }
215              }
216            }
217          }
218        }
219      }
220    }
221
222    format!("    {}", month_line)
223  }
224
225  #[allow(dead_code)]
226  pub fn calculate_total_contributions(&self) -> u32 {
227    self
228      .weeks
229      .iter()
230      .flat_map(|w| &w.contribution_days)
231      .map(|d| d.contribution_count)
232      .sum()
233  }
234
235  #[allow(dead_code)]
236  pub fn calculate_streaks(&self) -> (u32, u32) {
237    let mut all_contributions: Vec<u32> = self
238      .weeks
239      .iter()
240      .flat_map(|w| &w.contribution_days)
241      .map(|d| d.contribution_count)
242      .collect();
243
244    all_contributions.reverse();
245
246    let mut current_streak = 0;
247    for &count in &all_contributions {
248      if count > 0 {
249        current_streak += 1;
250      } else {
251        break;
252      }
253    }
254
255    let mut max_streak = 0;
256    let mut temp_streak = 0;
257    for &count in &all_contributions {
258      if count > 0 {
259        temp_streak += 1;
260        max_streak = max_streak.max(temp_streak);
261      } else {
262        temp_streak = 0;
263      }
264    }
265
266    (current_streak, max_streak)
267  }
268}