feat: add your contribution graph to the Stats tab

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.
This commit is contained in:
dualfroz committed 2026-07-11 22:32:32 +02:00
1 parent 4723d18c59
commit 1000359393
3 files changed
+102 -5

No files matched your search

+15
View File
@@ -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<crate::stats::OwnCommit> = 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<String, usize> = 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,
});
});
+5
View File
@@ -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,
+82 -5
View File
@@ -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<Line> = 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<Line> = 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<Line> = 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);