// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The dashboard's contribution activity: a 53-week commit heatmap and a recent //! commit feed, computed across the viewer's own repositories. //! //! Commits are attributed to the viewer by author email (case-insensitive) over //! the trailing 53 weeks. Scanning is bounded per repo so a large history never //! makes the dashboard slow. use std::cmp::Reverse; use std::fmt::Write as _; use maud::{Markup, PreEscaped, html}; use model::User; use time::{Duration, OffsetDateTime}; use crate::AppState; use crate::error::AppResult; use crate::icons::{Icon, icon}; use crate::repo::git_read; /// Weeks shown across the heatmap (a year plus the current partial week). const WEEKS: usize = 53; /// Commits scanned per repo on its default branch — bounds dashboard cost. const SCAN_PER_REPO: usize = 400; /// The most recent commits retained for the (paginated) activity feed. const FEED_LIMIT: usize = 100; /// One commit in the activity feed. pub struct FeedItem { /// The repo's owner username. pub owner: String, /// The repo path. pub repo: String, /// The branch the commit is on (the default branch). pub branch: String, /// The abbreviated commit id. pub short: String, /// The full commit id, for the link. pub oid: String, /// The commit summary line. pub summary: String, /// Author time, epoch milliseconds. pub time_ms: i64, } /// The computed activity for a user. pub struct Activity { /// Per-cell commit counts, indexed `week * 7 + weekday` (weekday 0 = Monday). counts: Vec, /// Total commits in the window. total: u32, /// The Monday of the first (leftmost) column. start: time::Date, /// The most recent commits, newest first. feed: Vec, } /// Compute the viewer's activity across their own repositories. pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult { let repos = state.store.repos_by_owner(&user.id).await?; let email = user.email.to_lowercase(); // The grid ends today and starts on the Monday 53 weeks back. let today = OffsetDateTime::from_unix_timestamp(now_ms.div_euclid(1000)) .unwrap_or(OffsetDateTime::UNIX_EPOCH) .date(); let this_monday = today - Duration::days(i64::from(today.weekday().number_days_from_monday())); let start = this_monday - Duration::days(i64::try_from((WEEKS - 1) * 7).unwrap_or(0)); let mut counts = vec![0u32; WEEKS * 7]; let mut feed: Vec = Vec::new(); for repo in &repos { // Walk every branch (deduplicated) so work on non-default branches counts. let commits = git_read(state, &repo.id, move |r| { r.commits_all_branches(SCAN_PER_REPO) }) .await; let Ok(commits) = commits else { continue }; for (c, branch) in commits { if !c.author.email.eq_ignore_ascii_case(&email) { continue; } // Bucket into the heatmap when inside the window. if let Ok(dt) = OffsetDateTime::from_unix_timestamp(c.author.time_ms.div_euclid(1000)) { let offset = (dt.date() - start).whole_days(); let total_days = i64::try_from(WEEKS * 7).unwrap_or(0); if (0..total_days).contains(&offset) && let Ok(idx) = usize::try_from(offset) { counts[idx] += 1; } } feed.push(FeedItem { owner: user.username.clone(), repo: repo.path.clone(), branch, short: c.oid.short().to_string(), oid: c.oid.to_string(), summary: c.summary.clone(), time_ms: c.author.time_ms, }); } } let total = counts.iter().sum(); feed.sort_by_key(|i| Reverse(i.time_ms)); feed.truncate(FEED_LIMIT); Ok(Activity { counts, total, start, feed, }) } impl Activity { /// The intensity level (0–4) for a commit count, scaled to the busiest day. fn level(&self, count: u32) -> u8 { if count == 0 { return 0; } let max = self.counts.iter().copied().max().unwrap_or(1).max(1); let step = count.saturating_sub(1).saturating_mul(4) / max; 1 + u8::try_from(step.min(3)).unwrap_or(3) } /// Render the contribution heatmap as an inline SVG with month and weekday /// labels. pub fn heatmap(&self) -> Markup { // Geometry, in SVG user units. let cell = 11u32; let gap = 3u32; let step = cell + gap; let left = 30u32; // weekday-label gutter let top = 18u32; // month-label strip let width = left + u32::try_from(WEEKS).unwrap_or(53) * step; let height = top + 7 * step; let month_name = |m: time::Month| match m { time::Month::January => "Jan", time::Month::February => "Feb", time::Month::March => "Mar", time::Month::April => "Apr", time::Month::May => "May", time::Month::June => "Jun", time::Month::July => "Jul", time::Month::August => "Aug", time::Month::September => "Sep", time::Month::October => "Oct", time::Month::November => "Nov", time::Month::December => "Dec", }; let mut body = String::new(); // Month labels: at the first column whose Monday introduces a new month. let mut prev_month: Option = None; for week in 0..WEEKS { let wk = u32::try_from(week).unwrap_or(0); let col_date = self.start + Duration::days(i64::from(wk) * 7); let m = col_date.month(); if prev_month != Some(m) { prev_month = Some(m); let x = left + wk * step; let _ = write!( body, r#"{}"#, month_name(m) ); } } // Weekday labels (Mon/Wed/Fri). for (row, label) in [(0u32, "Mon"), (2, "Wed"), (4, "Fri")] { let y = top + row * step + cell; let _ = write!( body, r#"{label}"# ); } // Cells. for week in 0..WEEKS { let wk = u32::try_from(week).unwrap_or(0); for day in 0..7u32 { let idx = week * 7 + usize::try_from(day).unwrap_or(0); let count = self.counts[idx]; let x = left + wk * step; let y = top + day * step; let lvl = self.level(count); let date = self.start + Duration::days(i64::try_from(idx).unwrap_or(0)); let _ = write!( body, r#"{count} on {}-{:02}-{:02}"#, date.year(), u8::from(date.month()), date.day(), ); } } html! { div class="heatmap-wrap" { // width:100% + the viewBox aspect ratio stretches the graph to // fill the activity column (styled in base.css). svg class="heatmap" viewBox=(format!("0 0 {width} {height}")) preserveAspectRatio="xMidYMid meet" role="img" aria-label=(format!("{} contributions in the last year", self.total)) { (PreEscaped(body)) } } p class="muted heatmap-caption" { (self.total) " contributions in the last year" } } } /// Render the recent-activity commit feed, paginated. `path`/`query` are the /// dashboard URL and its query (so the page-size selector and prev/next keep /// any repo-search term). pub fn feed_section( &self, now_ms: i64, paging: crate::repo::Pagination, path: &str, query: Option<&str>, ) -> Markup { let start = paging.offset().min(self.feed.len()); let end = start.saturating_add(paging.per_page).min(self.feed.len()); let page = &self.feed[start..end]; let has_next = end < self.feed.len(); html! { section class="listing" { h2 { "Recent activity" } @if self.feed.is_empty() { div class="card" { p class="muted" { "No recent activity." } } } @else { ul class="activity-feed card" { @for item in page { @let base = format!("/{}/{}", item.owner, item.repo); li class="activity-item" { div class="activity-head muted" { (icon(Icon::GitBranch)) " pushed to " span class="badge" { (item.branch) } " at " a href=(base) { (item.owner) "/" (item.repo) } span class="activity-time" { (fmt_relative(item.time_ms, now_ms)) } } a class="activity-commit" href=(format!("{base}/-/commit/{}", item.oid)) { span class="mono commit-sha" { (item.short) } span class="commit-summary" { (item.summary) } } } } } @if has_next || paging.page > 1 { (crate::repo::pagination_nav(path, query, paging, has_next)) } } } } } } /// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours /// ago`, falling back to an absolute date past a month. pub(crate) fn fmt_relative(ms: i64, now_ms: i64) -> String { let secs = (now_ms - ms).div_euclid(1000).max(0); let mins = secs / 60; let hours = mins / 60; let days = hours / 24; if secs < 60 { "just now".to_string() } else if mins < 60 { plural(mins, "minute") } else if hours < 24 { plural(hours, "hour") } else if days < 30 { plural(days, "day") } else { match OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)) { Ok(dt) => format!( "on {}-{:02}-{:02}", dt.year(), u8::from(dt.month()), dt.day() ), Err(_) => "a while ago".to_string(), } } } /// `N unit(s) ago`, pluralizing the unit. fn plural(n: i64, unit: &str) -> String { if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") } }