1use chrono::{DateTime, TimeDelta, Utc};
2use crossterm::event::{KeyCode, KeyEvent};
3
4use crate::git::kit::KitRepo;
5use crate::git::model::KitCommit;
6use crate::tui::Renderable;
7use crate::{error::Result, tui::ACCENT};
8
9use ratatui::{
10 Frame,
11 layout::{Alignment, Constraint, Layout, Rect},
12 style::{Color, Modifier, Style},
13 text::{Line, Span},
14 widgets::{
15 BarChart, Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Table,
16 TableState,
17 },
18};
19
20#[derive(Debug, Clone)]
21pub struct CadenceData {
22 pub global_commits_per_week: u32,
23 pub author_commits_per_week: Vec<AuthorCommits>,
24}
25
26#[derive(Debug)]
27pub struct CadencePage {
28 pub data: CadenceData,
29 pub selected_index: usize,
30 pub selected_author: Option<AuthorDetails>,
31 pub table_state: TableState,
32}
33
34#[derive(Debug, Clone)]
35
36pub struct AuthorCommits {
38 pub name: String,
39 pub commits_per_week: u32,
40}
41
42#[derive(Debug)]
43pub struct AuthorDetails {
44 pub name: String, pub commits_per_week: u32,
46 pub first_commit: String,
47 pub total_commits: u32,
48 pub repo_share: f64,
49}
50
51impl Renderable for CadencePage {
52 fn render(&mut self, frame: &mut Frame, area: Rect) {
53 let block = Block::default().padding(Padding::horizontal(1));
54
55 frame.render_widget(&block, area);
56
57 let inner_area = block.inner(area);
58
59 let left_constraint = Constraint::Percentage(60);
60 let right_constraint = Constraint::Percentage(40);
61 let middle_spacer = Constraint::Percentage(2);
62
63 let main_columns = Layout::horizontal([left_constraint, middle_spacer, right_constraint])
64 .split(inner_area);
65
66 let left_column = main_columns[0];
67 let right_column = main_columns[2];
68
69 self.author_table(frame, left_column);
70 self.chart(frame, right_column);
71
72 if let Some(details) = &self.selected_author {
74 self.more_info(frame, details);
75 }
76 }
77}
78
79impl CadencePage {
80 pub fn new(data: CadenceData) -> Self {
81 Self {
82 data,
83 selected_index: 0,
84 selected_author: None,
85 table_state: TableState::default().with_selected(Some(0)),
86 }
87 }
88
89 pub fn handle_key(&mut self, key_event: KeyEvent, repo: &KitRepo) {
90 match key_event.code {
91 KeyCode::Down | KeyCode::Char('j') => self.next_index(),
92 KeyCode::Up | KeyCode::Char('k') => self.previous_index(),
93 KeyCode::Enter => self.select(repo),
94 KeyCode::Esc | KeyCode::Backspace => self.unselect(),
95 _ => {}
96 };
97 }
98
99 pub fn next_index(&mut self) {
100 if !self.data.author_commits_per_week.is_empty() {
101 self.selected_index =
102 (self.selected_index + 1) % self.data.author_commits_per_week.len();
103 self.table_state.select(Some(self.selected_index));
104 }
105 }
106
107 pub fn previous_index(&mut self) {
108 if !self.data.author_commits_per_week.is_empty() {
109 if self.selected_index == 0 {
110 self.selected_index = self.data.author_commits_per_week.len() - 1;
111 } else {
112 self.selected_index -= 1;
113 }
114
115 self.table_state.select(Some(self.selected_index));
116 }
117 }
118
119 pub fn unselect(&mut self) {
121 self.selected_author = None;
122 }
123
124 pub fn select(&mut self, repo: &KitRepo) {
126 if self.selected_author.take().is_some() {
127 return;
128 }
129
130 let AuthorCommits {
131 name,
132 commits_per_week,
133 } = self.data.author_commits_per_week[self.selected_index].clone();
134
135 let first_commit = CadenceData::author_first_commit(repo, &name)
136 .ok()
137 .flatten()
138 .map(|commit| {
139 commit
140 .date
141 .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string())
142 .unwrap_or_else(|| commit.time_seconds.to_string())
143 })
144 .unwrap_or_else(String::new);
145
146 let total_commits = repo
147 .get_author_commits(&name)
148 .map_or(0, |iter| iter.count()) as u32;
149
150 let repo_share = CadenceData::author_repository_share(repo, &name).unwrap_or(0.0);
151
152 let details = AuthorDetails {
153 name: name,
154 commits_per_week: commits_per_week,
155 first_commit,
156 total_commits,
157 repo_share,
158 };
159 self.selected_author = Some(details);
160 }
161
162 fn chart(&self, frame: &mut Frame, area: Rect) {
163 let mut authors: Vec<(&String, &u32)> = self
164 .data
165 .author_commits_per_week
166 .iter()
167 .map(|ac| (&ac.name, &ac.commits_per_week))
168 .collect();
169 authors.sort_by(|a, b| a.1.cmp(b.1));
170
171 let chart_data: Vec<(&str, u64)> = authors
172 .into_iter()
173 .map(|(author, commits)| (author.as_str(), ((*commits) as f32).round() as u64))
174 .filter(|(_, commits)| *commits > 0) .collect();
176
177 let chart = BarChart::default()
178 .block(
179 Block::default()
180 .title(" Activity Overview ")
181 .borders(Borders::ALL),
182 )
183 .data(&chart_data)
184 .bar_width(5)
185 .bar_gap(2)
186 .bar_style(Style::default().fg(ACCENT))
187 .value_style(Style::default().fg(Color::Black).bg(ACCENT));
188
189 frame.render_widget(chart, area);
190 }
191
192 fn author_table(&mut self, frame: &mut Frame, area: Rect) {
193 let widths = [Constraint::Percentage(50), Constraint::Percentage(30)];
194
195 let rows: Vec<Row> = self
196 .data
197 .author_commits_per_week
198 .iter()
199 .map(|item| {
200 Row::new(vec![
201 Cell::from(item.name.clone())
202 .style(Style::default().add_modifier(Modifier::BOLD)),
203 Cell::from(format!("{:.2} / week", item.commits_per_week))
204 .style(Style::default().fg(Color::DarkGray)),
205 ])
206 })
207 .collect();
208
209 let table = Table::new(rows, widths)
210 .block(Block::default().title(" Authors ").borders(Borders::ALL))
211 .row_highlight_style(ACCENT)
212 .highlight_symbol("> ");
213
214 frame.render_stateful_widget(table, area, &mut self.table_state);
215 }
216
217 pub fn more_info(&self, frame: &mut Frame, details: &AuthorDetails) {
218 let area = frame
219 .area()
220 .centered(Constraint::Percentage(25), Constraint::Percentage(25));
221
222 let title = format!(" {} ", details.name);
223
224 let block = Block::bordered()
225 .border_type(BorderType::Thick)
226 .border_style(Style::default().fg(ACCENT))
227 .title(title)
228 .title_style(Color::White)
229 .title_alignment(Alignment::Center);
230
231 let key_style = Style::default().fg(Color::White);
232 let text = vec![
233 Line::from(""),
234 Line::from(vec![
235 Span::styled(" Total Commits: ", key_style),
236 Span::raw(format!("{}", details.total_commits)),
237 ]),
238 Line::from(vec![
239 Span::styled(" Commits/Week: ", key_style),
240 Span::raw(format!("{}", details.commits_per_week)),
241 ]),
242 Line::from(vec![
243 Span::styled(" First Commit: ", key_style),
244 Span::raw(format!("{}", details.first_commit)),
245 ]),
246 Line::from(vec![
247 Span::styled(" Repo Share: ", key_style),
248 Span::raw(format!("{:.2}%", details.repo_share)),
249 ]),
250 ];
251
252 let paragraph = Paragraph::new(text).block(block).alignment(Alignment::Left);
253
254 frame.render_widget(Clear, area); frame.render_widget(paragraph, area);
256 }
257}
258
259impl CadenceData {
260 pub fn author_first_commit<'a>(repo: &'a KitRepo, email: &str) -> Result<Option<KitCommit>> {
261 let commits = repo.get_author_commits(email)?;
262 Ok(commits.last()) }
264
265 pub fn author_repository_share(repo: &KitRepo, email: &str) -> Result<f64> {
266 let author_count = repo.get_author_commits(email)?.count();
267 let repo_count = repo.iter_commits()?.count();
268
269 if repo_count == 0 {
270 return Ok(0.0);
271 }
272
273 let share = (author_count as f64) / (repo_count as f64);
274
275 let percentage = share * 100.0;
276 Ok(percentage)
277 }
278
279 pub fn author_commits_per_week(repo: &KitRepo, email: &str) -> Result<u32> {
280 let commit_dates: Vec<DateTime<Utc>> = repo
281 .get_author_commits(email)?
282 .filter_map(|commit| commit.date)
283 .collect();
284
285 Ok(commits_per_week(&commit_dates))
286 }
287
288 pub fn global_commits_per_week(repo: &KitRepo) -> Result<u32> {
289 let commit_dates: Vec<DateTime<Utc>> = repo
290 .iter_commits()?
291 .filter_map(|commit| commit.date)
292 .collect();
293
294 Ok(commits_per_week(&commit_dates))
295 }
296
297 pub fn new(repo: &KitRepo) -> Self {
298 let mut cadence = CadenceData {
299 global_commits_per_week: Self::global_commits_per_week(repo).unwrap_or(0),
300 author_commits_per_week: Vec::new(),
301 };
302 for author in repo.get_authors().unwrap_or_default() {
303 if let Ok(author_commits) = repo.get_author_commits(&author) {
304 let commit_dates: Vec<DateTime<Utc>> =
305 author_commits.filter_map(|commit| commit.date).collect();
306
307 cadence.author_commits_per_week.push(AuthorCommits {
308 name: author.clone(),
309 commits_per_week: commits_per_week(&commit_dates),
310 });
311 }
312 }
313 cadence
314 .author_commits_per_week
315 .sort_by(|a, b| b.commits_per_week.cmp(&a.commits_per_week));
316 cadence
317 }
318}
319
320fn commits_per_week(commits: &[DateTime<Utc>]) -> u32 {
321 match telescope_time(&commits) {
322 Some(delta) => {
323 let seconds_avg = delta.as_seconds_f32();
324 if seconds_avg > 0.0 {
325 ((1.0 / seconds_avg) * 60.0 * 60.0 * 24.0 * 7.0) as u32
326 } else {
327 0.0 as u32
328 }
329 }
330 None => 0.0 as u32,
331 }
332}
333
334fn telescope_time(datetimes: &[DateTime<Utc>]) -> Option<TimeDelta> {
336 if datetimes.len() < 2 {
337 return None;
338 }
339
340 let total_duration = *datetimes.first()? - *datetimes.last()?;
343 let count = (datetimes.len() - 1) as i32;
344
345 total_duration.checked_div(count)
346}