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    let ansi_color = get_ansi_color(color).unwrap_or_default();
160    format!("{}{}\x1b[0m ", ansi_color, custom_box)
161  }
162
163  fn get_recent_weeks(&self, limit: usize) -> Vec<Week> {
164    if self.weeks.len() <= limit {
165      self.weeks.clone()
166    } else {
167      self.weeks[self.weeks.len() - limit..].to_vec()
168    }
169  }
170
171  fn build_month_line(&self, weeks: &[Week]) -> String {
172    if weeks.is_empty() {
173      return String::new();
174    }
175
176    let months = [
177      "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
178    ];
179
180    let mut month_line = String::new();
181
182    for (idx, week) in weeks.iter().enumerate() {
183      if week.contribution_days.is_empty() {
184        continue;
185      }
186
187      let first_day = &week.contribution_days[0];
188      if let Ok(date) = NaiveDate::parse_from_str(&first_day.date, "%Y-%m-%d") {
189        let current_month = date.month() as usize;
190
191        if idx == 0 {
192          month_line.push_str(months[current_month - 1]);
193        } else {
194          if let Some(prev_week) = weeks.get(idx - 1) {
195            if !prev_week.contribution_days.is_empty() {
196              if let Ok(prev_date) =
197                NaiveDate::parse_from_str(&prev_week.contribution_days[0].date, "%Y-%m-%d")
198              {
199                let prev_month = prev_date.month() as usize;
200                if current_month != prev_month {
201                  let target_width = (idx + 1) * 2;
202                  let current_width = month_line.len();
203                  let month_name = months[current_month - 1];
204                  let needed_space = (target_width - current_width - month_name.len()).max(1);
205                  month_line.push_str(&" ".repeat(needed_space));
206                  month_line.push_str(month_name);
207                }
208              }
209            }
210          }
211        }
212      }
213    }
214
215    format!("    {}", month_line)
216  }
217
218  #[allow(dead_code)]
219  pub fn calculate_total_contributions(&self) -> u32 {
220    self
221      .weeks
222      .iter()
223      .flat_map(|w| &w.contribution_days)
224      .map(|d| d.contribution_count)
225      .sum()
226  }
227
228  #[allow(dead_code)]
229  pub fn calculate_streaks(&self) -> (u32, u32) {
230    let mut all_contributions: Vec<u32> = self
231      .weeks
232      .iter()
233      .flat_map(|w| &w.contribution_days)
234      .map(|d| d.contribution_count)
235      .collect();
236
237    all_contributions.reverse();
238
239    let mut current_streak = 0;
240    for &count in &all_contributions {
241      if count > 0 {
242        current_streak += 1;
243      } else {
244        break;
245      }
246    }
247
248    let mut max_streak = 0;
249    let mut temp_streak = 0;
250    for &count in &all_contributions {
251      if count > 0 {
252        temp_streak += 1;
253        max_streak = max_streak.max(temp_streak);
254      } else {
255        temp_streak = 0;
256      }
257    }
258
259    (current_streak, max_streak)
260  }
261}