From 10003593939d30bfe163adf4498cf5177f9c7c07 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 11 Jul 2026 22:32:32 +0200 Subject: [PATCH] feat: add your contribution graph to the Stats tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stats tab counted every commit on HEAD (mostly upstream history in a workspace full of forks), so "your activity" was fictional. It now leads with an author-filtered panel: current/longest day streak, a GitHub-style 52-week contribution heatmap, and an hour-of-day histogram — computed from your identities via the same scan the weekly digest uses, in the existing background stats thread. The all-commits period bar chart moves below it; languages/top-repos/day-of-week stay on the right. --- src/hub/keys.rs | 15 ++++++++ src/hub/state_bg.rs | 5 +++ src/hub/tabs_stats.rs | 87 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 0804513..8136ec7 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -24,10 +24,24 @@ pub fn maybe_load_global_stats(state: &mut HubState) { let dirty_count = state.repos.iter().filter(|r| r.is_dirty).count(); let behind_count = state.repos.iter().filter(|r| r.behind > 0).count(); let period = state.global_stats_period; + let cfg = state.cfg.clone(); + let accounts = state.accounts.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { use super::state::GlobalStats; + + // Author-filtered "your contributions" — one git scan across the + // workspace (cheap since the `-E` fix), independent of the period + // selector: streak/heatmap/hours are your whole activity graph. + let matcher = crate::stats::OwnEmailMatcher::resolve(&cfg, &accounts); + let own_commits: Vec = repos_snapshot.iter() + .flat_map(|(_, path, _, _, _)| crate::stats::scan_repo_own_commits(path, &matcher)) + .collect(); + let own_total = own_commits.len(); + let own_streak = crate::stats::streak(&own_commits); + let own_heatmap = crate::stats::heatmap_52w(&own_commits); + let own_hours = crate::stats::hour_pattern(&own_commits, 365); let mut all_dates: std::collections::HashMap = std::collections::HashMap::new(); let mut top_repos: Vec<(String, usize)> = vec![]; let mut total_commits: usize = 0; @@ -81,6 +95,7 @@ pub fn maybe_load_global_stats(state: &mut HubState) { activity, lang_dist, top_repos, repo_commits, total_commits, dirty_count, behind_count, day_pattern, + own_total, own_streak, own_heatmap, own_hours, }); }); diff --git a/src/hub/state_bg.rs b/src/hub/state_bg.rs index c735100..0c69fc2 100644 --- a/src/hub/state_bg.rs +++ b/src/hub/state_bg.rs @@ -17,6 +17,11 @@ pub struct GlobalStats { pub dirty_count: usize, pub behind_count: usize, pub day_pattern: [usize; 7], // Mon–Sun commit totals + // ── your (author-filtered) contributions, all-time ── + pub own_total: usize, // commits authored by you + pub own_streak: (u32, u32), // (current, longest) day streak + pub own_heatmap: Vec<(chrono::NaiveDate, u32)>, // ~52w daily own-commit counts + pub own_hours: [u32; 24], // your commits by local hour } /// Progress/result of the background API refresh ([R]): per-repo metadata, diff --git a/src/hub/tabs_stats.rs b/src/hub/tabs_stats.rs index e0f20b9..33f185d 100644 --- a/src/hub/tabs_stats.rs +++ b/src/hub/tabs_stats.rs @@ -22,11 +22,19 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) { .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(area); - // LEFT: bar chart or loading + // LEFT is split: your author-filtered contribution graph on top, the + // period commit bar chart below it. + let left_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(13), Constraint::Min(3)]) + .split(cols[0]); + render_own_contributions(f, state, left_rows[0]); + let chart_area = left_rows[1]; + let mut left_lines: Vec = vec![]; left_lines.push(Line::from(Span::styled( - format!(" Global Statistics [7:{}] [p] change period [d] weekly digest preview", state.global_stats_period.label()), + format!(" Global Statistics [7:{}] [p] period [d] digest", state.global_stats_period.label()), yel, ))); left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────────────────", dim))); @@ -46,10 +54,10 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) { // Simple text bar chart let max_count: usize = gs.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1); - let bar_h = (area.height as usize).saturating_sub(10).max(5); + let bar_h = (chart_area.height as usize).saturating_sub(6).max(4); let data = &gs.activity; let n = data.len(); - let bar_w = (cols[0].width as usize).saturating_sub(8).max(1); + let bar_w = (chart_area.width as usize).saturating_sub(8).max(1); let step = if n > bar_w { (n / bar_w).max(1) } else { 1 }; let sampled: Vec<&(String, usize)> = data.iter().step_by(step).collect(); let bar_count = sampled.len().min(bar_w); @@ -89,7 +97,7 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) { } } - f.render_widget(Paragraph::new(left_lines), cols[0]); + f.render_widget(Paragraph::new(left_lines), chart_area); // RIGHT: language breakdown + top repos let mut right_lines: Vec = vec![]; @@ -145,6 +153,75 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) { f.render_widget(Paragraph::new(right_lines), cols[1]); } +/// Your author-filtered contribution graph: streak + totals, a GitHub-style +/// 52-week heatmap, and an hour-of-day histogram. Author-scoped (only commits +/// by your configured identities), unlike the all-commits chart beside it. +fn render_own_contributions(f: &mut Frame, state: &HubState, area: Rect) { + let dim = Style::default().fg(Color::DarkGray); + let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let grn = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD); + + let Some(gs) = &state.global_stats else { + f.render_widget(Paragraph::new(Line::from(Span::styled(" Your contributions — loading…", dim))), area); + return; + }; + + let mut lines: Vec = vec![ + Line::from(Span::styled(" Your Contributions", yel)), + ]; + + if gs.own_total == 0 { + lines.push(Line::from(Span::styled( + " no commits matched your identities — set stats_author_emails", dim))); + f.render_widget(Paragraph::new(lines), area); + return; + } + + let (cur, max) = gs.own_streak; + lines.push(Line::from(vec![ + Span::styled(" streak ", dim), + Span::styled(format!("{cur}d"), grn), + Span::styled(format!(" · longest {max}d"), dim), + Span::styled(format!(" · {} commits/yr", gs.own_total), Style::default().fg(Color::White)), + ])); + lines.push(Line::from("")); + + // 52-week heatmap: 7 rows (Mon..Sun) × one column per week, shaded by count. + let hm = &gs.own_heatmap; + let weeks = hm.len().div_ceil(7); + let day_lbl = ["Mon", "", "Wed", "", "Fri", "", "Sun"]; + for (row, lbl) in day_lbl.iter().enumerate() { + let mut spans = vec![Span::styled(format!(" {:<4}", lbl), dim)]; + for w in 0..weeks { + let c = hm.get(w * 7 + row).map(|(_, c)| *c).unwrap_or(0); + let (ch, st) = match c { + 0 => ("·", dim), + 1 => ("■", Style::default().fg(Color::Rgb(14, 68, 41))), + 2..=3 => ("■", Style::default().fg(Color::Rgb(0, 109, 50))), + 4..=6 => ("■", Style::default().fg(Color::Rgb(38, 166, 65))), + _ => ("■", Style::default().fg(Color::Rgb(57, 211, 83))), + }; + spans.push(Span::styled(ch, st)); + } + lines.push(Line::from(spans)); + } + lines.push(Line::from("")); + + // Hour-of-day histogram as a 24-cell sparkline. + let hours = &gs.own_hours; + let hmax = hours.iter().copied().max().unwrap_or(1).max(1) as usize; + let ramp = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let mut hspans = vec![Span::styled(" hours ", dim)]; + for &h in hours.iter() { + let lvl = (h as usize * 8 / hmax).min(8); + hspans.push(Span::styled(ramp[lvl].to_string(), Style::default().fg(Color::Cyan))); + } + hspans.push(Span::styled(" 0–23h", dim)); + lines.push(Line::from(hspans)); + + f.render_widget(Paragraph::new(lines), area); +} + pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) { use ratatui::widgets::{Block, Borders}; let dim = Style::default().fg(Color::DarkGray);