Skip to main content

gitkit_cli/metrics/
cadence.rs

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
36// remove this type
37pub struct AuthorCommits {
38    pub name: String,
39    pub commits_per_week: u32,
40}
41
42#[derive(Debug)]
43pub struct AuthorDetails {
44    pub name: String, // TODO:  fix these props later
45    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        // show more info frame last, this will draw it on top
73        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    // used to unselect (e.g using Esc)
120    pub fn unselect(&mut self) {
121        self.selected_author = None;
122    }
123
124    // select author from commit list and fetch data for the more_info() window
125    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) // remove non-commiters to save space
175            .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); // clear to remove underneath text
255        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()) // the commit list is in reverse order
263    }
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
334//https://en.wikipedia.org/wiki/Telescoping_series
335fn telescope_time(datetimes: &[DateTime<Utc>]) -> Option<TimeDelta> {
336    if datetimes.len() < 2 {
337        return None;
338    }
339
340    // the middle dates all cancel when summing over their differences as pairs
341    // and we are left with the first and last only
342    let total_duration = *datetimes.first()? - *datetimes.last()?;
343    let count = (datetimes.len() - 1) as i32;
344
345    total_duration.checked_div(count)
346}