fabrica

hanna/fabrica

11393 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The dashboard's contribution activity: a 53-week commit heatmap and a recent
6//! commit feed, computed across the viewer's own repositories.
7//!
8//! Commits are attributed to the viewer by author email (case-insensitive) over
9//! the trailing 53 weeks. Scanning is bounded per repo so a large history never
10//! makes the dashboard slow.
11
12use std::cmp::Reverse;
13use std::fmt::Write as _;
14
15use maud::{Markup, PreEscaped, html};
16use model::User;
17use time::{Duration, OffsetDateTime};
18
19use crate::AppState;
20use crate::error::AppResult;
21use crate::icons::{Icon, icon};
22use crate::repo::git_read;
23
24/// Weeks shown across the heatmap (a year plus the current partial week).
25const WEEKS: usize = 53;
26/// Commits scanned per repo on its default branch — bounds dashboard cost.
27const SCAN_PER_REPO: usize = 400;
28/// The most recent commits retained for the (paginated) activity feed.
29const FEED_LIMIT: usize = 100;
30
31/// One commit in the activity feed.
32pub struct FeedItem {
33 /// The repo's owner username.
34 pub owner: String,
35 /// The repo path.
36 pub repo: String,
37 /// The branch the commit is on (the default branch).
38 pub branch: String,
39 /// The abbreviated commit id.
40 pub short: String,
41 /// The full commit id, for the link.
42 pub oid: String,
43 /// The commit summary line.
44 pub summary: String,
45 /// Author time, epoch milliseconds.
46 pub time_ms: i64,
47}
48
49/// The computed activity for a user.
50pub struct Activity {
51 /// Per-cell commit counts, indexed `week * 7 + weekday` (weekday 0 = Monday).
52 counts: Vec<u32>,
53 /// Total commits in the window.
54 total: u32,
55 /// The Monday of the first (leftmost) column.
56 start: time::Date,
57 /// The most recent commits, newest first.
58 feed: Vec<FeedItem>,
59}
60
61/// Compute the viewer's activity across their own repositories.
62pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult<Activity> {
63 let repos = state.store.repos_by_owner(&user.id).await?;
64 let email = user.email.to_lowercase();
65
66 // The grid ends today and starts on the Monday 53 weeks back.
67 let today = OffsetDateTime::from_unix_timestamp(now_ms.div_euclid(1000))
68 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
69 .date();
70 let this_monday = today - Duration::days(i64::from(today.weekday().number_days_from_monday()));
71 let start = this_monday - Duration::days(i64::try_from((WEEKS - 1) * 7).unwrap_or(0));
72
73 let mut counts = vec![0u32; WEEKS * 7];
74 let mut feed: Vec<FeedItem> = Vec::new();
75
76 for repo in &repos {
77 // Walk every branch (deduplicated) so work on non-default branches counts.
78 let commits = git_read(state, &repo.id, move |r| {
79 r.commits_all_branches(SCAN_PER_REPO)
80 })
81 .await;
82 let Ok(commits) = commits else { continue };
83
84 for (c, branch) in commits {
85 if !c.author.email.eq_ignore_ascii_case(&email) {
86 continue;
87 }
88 // Bucket into the heatmap when inside the window.
89 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(c.author.time_ms.div_euclid(1000)) {
90 let offset = (dt.date() - start).whole_days();
91 let total_days = i64::try_from(WEEKS * 7).unwrap_or(0);
92 if (0..total_days).contains(&offset)
93 && let Ok(idx) = usize::try_from(offset)
94 {
95 counts[idx] += 1;
96 }
97 }
98 feed.push(FeedItem {
99 owner: user.username.clone(),
100 repo: repo.path.clone(),
101 branch,
102 short: c.oid.short().to_string(),
103 oid: c.oid.to_string(),
104 summary: c.summary.clone(),
105 time_ms: c.author.time_ms,
106 });
107 }
108 }
109
110 let total = counts.iter().sum();
111 feed.sort_by_key(|i| Reverse(i.time_ms));
112 feed.truncate(FEED_LIMIT);
113
114 Ok(Activity {
115 counts,
116 total,
117 start,
118 feed,
119 })
120}
121
122impl Activity {
123 /// The intensity level (0–4) for a commit count, scaled to the busiest day.
124 fn level(&self, count: u32) -> u8 {
125 if count == 0 {
126 return 0;
127 }
128 let max = self.counts.iter().copied().max().unwrap_or(1).max(1);
129 let step = count.saturating_sub(1).saturating_mul(4) / max;
130 1 + u8::try_from(step.min(3)).unwrap_or(3)
131 }
132
133 /// Render the contribution heatmap as an inline SVG with month and weekday
134 /// labels.
135 pub fn heatmap(&self) -> Markup {
136 // Geometry, in SVG user units.
137 let cell = 11u32;
138 let gap = 3u32;
139 let step = cell + gap;
140 let left = 30u32; // weekday-label gutter
141 let top = 18u32; // month-label strip
142 let width = left + u32::try_from(WEEKS).unwrap_or(53) * step;
143 let height = top + 7 * step;
144
145 let month_name = |m: time::Month| match m {
146 time::Month::January => "Jan",
147 time::Month::February => "Feb",
148 time::Month::March => "Mar",
149 time::Month::April => "Apr",
150 time::Month::May => "May",
151 time::Month::June => "Jun",
152 time::Month::July => "Jul",
153 time::Month::August => "Aug",
154 time::Month::September => "Sep",
155 time::Month::October => "Oct",
156 time::Month::November => "Nov",
157 time::Month::December => "Dec",
158 };
159
160 let mut body = String::new();
161 // Month labels: at the first column whose Monday introduces a new month.
162 let mut prev_month: Option<time::Month> = None;
163 for week in 0..WEEKS {
164 let wk = u32::try_from(week).unwrap_or(0);
165 let col_date = self.start + Duration::days(i64::from(wk) * 7);
166 let m = col_date.month();
167 if prev_month != Some(m) {
168 prev_month = Some(m);
169 let x = left + wk * step;
170 let _ = write!(
171 body,
172 r#"<text x="{x}" y="12" class="hm-label">{}</text>"#,
173 month_name(m)
174 );
175 }
176 }
177 // Weekday labels (Mon/Wed/Fri).
178 for (row, label) in [(0u32, "Mon"), (2, "Wed"), (4, "Fri")] {
179 let y = top + row * step + cell;
180 let _ = write!(
181 body,
182 r#"<text x="0" y="{y}" class="hm-label">{label}</text>"#
183 );
184 }
185 // Cells.
186 for week in 0..WEEKS {
187 let wk = u32::try_from(week).unwrap_or(0);
188 for day in 0..7u32 {
189 let idx = week * 7 + usize::try_from(day).unwrap_or(0);
190 let count = self.counts[idx];
191 let x = left + wk * step;
192 let y = top + day * step;
193 let lvl = self.level(count);
194 let date = self.start + Duration::days(i64::try_from(idx).unwrap_or(0));
195 let _ = write!(
196 body,
197 r#"<rect x="{x}" y="{y}" width="{cell}" height="{cell}" rx="2" class="hm-cell" data-level="{lvl}"><title>{count} on {}-{:02}-{:02}</title></rect>"#,
198 date.year(),
199 u8::from(date.month()),
200 date.day(),
201 );
202 }
203 }
204
205 html! {
206 div class="heatmap-wrap" {
207 // width:100% + the viewBox aspect ratio stretches the graph to
208 // fill the activity column (styled in base.css).
209 svg class="heatmap" viewBox=(format!("0 0 {width} {height}"))
210 preserveAspectRatio="xMidYMid meet" role="img"
211 aria-label=(format!("{} contributions in the last year", self.total)) {
212 (PreEscaped(body))
213 }
214 }
215 p class="muted heatmap-caption" {
216 (self.total) " contributions in the last year"
217 }
218 }
219 }
220
221 /// Render the recent-activity commit feed, paginated. `path`/`query` are the
222 /// dashboard URL and its query (so the page-size selector and prev/next keep
223 /// any repo-search term).
224 pub fn feed_section(
225 &self,
226 now_ms: i64,
227 paging: crate::repo::Pagination,
228 path: &str,
229 query: Option<&str>,
230 ) -> Markup {
231 let start = paging.offset().min(self.feed.len());
232 let end = start.saturating_add(paging.per_page).min(self.feed.len());
233 let page = &self.feed[start..end];
234 let has_next = end < self.feed.len();
235 html! {
236 section class="listing" {
237 h2 { "Recent activity" }
238 @if self.feed.is_empty() {
239 div class="card" { p class="muted" { "No recent activity." } }
240 } @else {
241 ul class="activity-feed card" {
242 @for item in page {
243 @let base = format!("/{}/{}", item.owner, item.repo);
244 li class="activity-item" {
245 div class="activity-head muted" {
246 (icon(Icon::GitBranch))
247 " pushed to "
248 span class="badge" { (item.branch) }
249 " at "
250 a href=(base) { (item.owner) "/" (item.repo) }
251 span class="activity-time" { (fmt_relative(item.time_ms, now_ms)) }
252 }
253 a class="activity-commit" href=(format!("{base}/-/commit/{}", item.oid)) {
254 span class="mono commit-sha" { (item.short) }
255 span class="commit-summary" { (item.summary) }
256 }
257 }
258 }
259 }
260 @if has_next || paging.page > 1 {
261 (crate::repo::pagination_nav(path, query, paging, has_next))
262 }
263 }
264 }
265 }
266 }
267}
268
269/// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours
270/// ago`, falling back to an absolute date past a month.
271pub(crate) fn fmt_relative(ms: i64, now_ms: i64) -> String {
272 let secs = (now_ms - ms).div_euclid(1000).max(0);
273 let mins = secs / 60;
274 let hours = mins / 60;
275 let days = hours / 24;
276 if secs < 60 {
277 "just now".to_string()
278 } else if mins < 60 {
279 plural(mins, "minute")
280 } else if hours < 24 {
281 plural(hours, "hour")
282 } else if days < 30 {
283 plural(days, "day")
284 } else {
285 match OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)) {
286 Ok(dt) => format!(
287 "on {}-{:02}-{:02}",
288 dt.year(),
289 u8::from(dt.month()),
290 dt.day()
291 ),
292 Err(_) => "a while ago".to_string(),
293 }
294 }
295}
296
297/// `N unit(s) ago`, pluralizing the unit.
298fn plural(n: i64, unit: &str) -> String {
299 if n == 1 {
300 format!("1 {unit} ago")
301 } else {
302 format!("{n} {unit}s ago")
303 }
304}