fabrica

hanna/fabrica

feat(web): dashboard with contribution graph and activity feed

8fa11e3 · hanna committed on 2026-07-25

Replace the signed-in landing with a Forgejo-style dashboard: a 53-week
contribution heatmap (commits attributed to the viewer by author email,
bounded scan per repo, themeable via color-mix on the accent) and a recent-
commit feed beside a filterable repository sidebar. Switch the tab strips
(repo sub-nav, explore) to an active-pill style, give the private badge a
visible border on Carbon, and mute the profile social-link icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +438 −16UnifiedSplit
assets/base.css +124 −11
@@ -359,6 +359,9 @@ select:focus {
359}359}
360.badge-private {360.badge-private {
361 color: var(--fb-fg-muted);361 color: var(--fb-fg-muted);
362 /* An explicit muted border: Carbon's --fb-border equals --fb-bg-inset, so the
363 default badge border vanishes against a hovered inset row. */
364 border-color: var(--fb-fg-muted);
362}365}
363366
364/* Inline search row: input grows, button sits to its right with margin. */367/* Inline search row: input grows, button sits to its right with margin. */
@@ -461,6 +464,103 @@ select:focus {
461 font-size: 0.85em;464 font-size: 0.85em;
462}465}
463466
467/* ---- Dashboard (activity + repo sidebar) ---- */
468.dashboard {
469 display: flex;
470 gap: 1.5rem;
471 align-items: flex-start;
472}
473.dashboard-main {
474 flex: 1;
475 min-width: 0;
476}
477.dashboard-side {
478 flex: none;
479 width: 20rem;
480}
481.dashboard-side .search-row {
482 margin-top: 0;
483}
484
485/* Contribution heatmap. */
486.heatmap-wrap {
487 overflow-x: auto;
488 padding-bottom: 0.25rem;
489}
490.heatmap {
491 display: block;
492}
493.hm-label {
494 fill: var(--fb-fg-muted);
495 font-size: 9px;
496 font-family: var(--fb-font-sans);
497}
498.hm-cell {
499 fill: var(--fb-bg-inset);
500}
501.hm-cell[data-level="1"] {
502 fill: color-mix(in srgb, var(--fb-accent) 30%, var(--fb-bg-inset));
503}
504.hm-cell[data-level="2"] {
505 fill: color-mix(in srgb, var(--fb-accent) 55%, var(--fb-bg-inset));
506}
507.hm-cell[data-level="3"] {
508 fill: color-mix(in srgb, var(--fb-accent) 78%, var(--fb-bg-inset));
509}
510.hm-cell[data-level="4"] {
511 fill: var(--fb-accent);
512}
513.heatmap-caption {
514 margin: 0.25rem 0 1.5rem;
515 font-size: 0.85em;
516}
517
518/* Recent-activity feed. */
519.activity-feed {
520 list-style: none;
521 margin: 0;
522 padding: 0;
523}
524.activity-item {
525 padding: 0.75rem 1rem;
526 border-bottom: 1px solid var(--fb-border);
527}
528.activity-item:last-child {
529 border-bottom: none;
530}
531.activity-head {
532 display: flex;
533 align-items: center;
534 gap: 0.4rem;
535 flex-wrap: wrap;
536 font-size: 0.9em;
537}
538.activity-time {
539 margin-left: auto;
540}
541.activity-commit {
542 display: flex;
543 gap: 0.6rem;
544 margin-top: 0.35rem;
545 align-items: baseline;
546 color: var(--fb-fg);
547}
548.activity-commit:hover {
549 color: var(--fb-accent);
550 text-decoration: none;
551}
552.commit-sha {
553 flex: none;
554 color: var(--fb-fg-muted);
555 font-size: 0.85em;
556}
557.commit-summary {
558 min-width: 0;
559 overflow: hidden;
560 text-overflow: ellipsis;
561 white-space: nowrap;
562}
563
464/* ---- Avatars & profile header ---- */564/* ---- Avatars & profile header ---- */
465.avatar {565.avatar {
466 border-radius: 6px;566 border-radius: 6px;
@@ -546,6 +646,18 @@ select:focus {
546 display: inline-flex;646 display: inline-flex;
547 align-items: center;647 align-items: center;
548 gap: 0.5rem;648 gap: 0.5rem;
649 min-width: 0;
650}
651.profile-links li .icon {
652 flex: none;
653 color: var(--fb-fg-muted);
654}
655.profile-links a {
656 color: var(--fb-accent);
657 min-width: 0;
658 overflow: hidden;
659 text-overflow: ellipsis;
660 white-space: nowrap;
549}661}
550.profile-joined {662.profile-joined {
551 margin: 0;663 margin: 0;
@@ -718,34 +830,33 @@ pre.code {
718.repo-slug a {830.repo-slug a {
719 color: var(--fb-accent);831 color: var(--fb-accent);
720}832}
721/* Horizontal tab strips (repo sub-nav, explore sub-nav). */833/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
722.repo-tabs,834.repo-tabs,
723.subnav {835.subnav {
724 display: flex;836 display: flex;
725 gap: 0.25rem;837 gap: 0.5rem;
726 flex-wrap: wrap;838 flex-wrap: wrap;
727 margin: 0.75rem 0;839 margin: 0.75rem 0 1.25rem;
728 border-bottom: 1px solid var(--fb-border);
729}840}
730.repo-tabs a,841.repo-tabs a,
731.subnav a {842.subnav a {
732 display: inline-flex;843 display: inline-flex;
733 align-items: center;844 align-items: center;
734 gap: 0.4rem;845 gap: 0.4rem;
735 padding: 0.5rem 0.85rem;846 padding: 0.4rem 0.9rem;
847 border-radius: 999px;
736 color: var(--fb-fg-muted);848 color: var(--fb-fg-muted);
737 border-bottom: 2px solid transparent;
738 margin-bottom: -1px;
739}849}
740.repo-tabs a:hover,850.repo-tabs a:hover,
741.subnav a:hover {851.subnav a:hover {
742 color: var(--fb-fg);852 color: var(--fb-fg);
853 background: var(--fb-bg-inset);
743 text-decoration: none;854 text-decoration: none;
744}855}
745.repo-tabs a[aria-current="page"],856.repo-tabs a[aria-current="page"],
746.subnav a[aria-current="page"] {857.subnav a[aria-current="page"] {
747 color: var(--fb-fg);858 color: var(--fb-fg);
748 border-bottom-color: var(--fb-accent);859 background: var(--fb-bg-inset);
749 font-weight: 600;860 font-weight: 600;
750}861}
751.subnav {862.subnav {
@@ -1018,11 +1129,13 @@ td.diff-empty {
1018 display: block;1129 display: block;
1019 overflow-x: auto;1130 overflow-x: auto;
1020 }1131 }
1021 /* Stack the profile columns on narrow screens. */1132 /* Stack the profile and dashboard columns on narrow screens. */
1022 .profile-layout {1133 .profile-layout,
1134 .dashboard {
1023 flex-direction: column;1135 flex-direction: column;
1024 }1136 }
1025 .profile-sidebar {1137 .profile-sidebar,
1138 .dashboard-side {
1026 width: 100%;1139 width: 100%;
1027 }1140 }
1028}1141}
crates/web/src/activity.rs +288 −0
@@ -0,0 +1,288 @@
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/// Recent commits shown in the activity feed.
29const FEED_LIMIT: usize = 20;
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 let rev_name = repo.default_branch.clone();
78 let commits = git_read(state, &repo.id, move |r| {
79 let rev = r.resolve_ref(&rev_name)?;
80 r.commits(&rev, None, git::Page::first(SCAN_PER_REPO))
81 })
82 .await;
83 let Ok(commits) = commits else { continue };
84
85 for c in commits {
86 if !c.author.email.eq_ignore_ascii_case(&email) {
87 continue;
88 }
89 // Bucket into the heatmap when inside the window.
90 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(c.author.time_ms.div_euclid(1000)) {
91 let offset = (dt.date() - start).whole_days();
92 let total_days = i64::try_from(WEEKS * 7).unwrap_or(0);
93 if (0..total_days).contains(&offset)
94 && let Ok(idx) = usize::try_from(offset)
95 {
96 counts[idx] += 1;
97 }
98 }
99 feed.push(FeedItem {
100 owner: user.username.clone(),
101 repo: repo.path.clone(),
102 branch: repo.default_branch.clone(),
103 short: c.oid.short().to_string(),
104 oid: c.oid.to_string(),
105 summary: c.summary.clone(),
106 time_ms: c.author.time_ms,
107 });
108 }
109 }
110
111 let total = counts.iter().sum();
112 feed.sort_by_key(|i| Reverse(i.time_ms));
113 feed.truncate(FEED_LIMIT);
114
115 Ok(Activity {
116 counts,
117 total,
118 start,
119 feed,
120 })
121}
122
123impl Activity {
124 /// The intensity level (0–4) for a commit count, scaled to the busiest day.
125 fn level(&self, count: u32) -> u8 {
126 if count == 0 {
127 return 0;
128 }
129 let max = self.counts.iter().copied().max().unwrap_or(1).max(1);
130 let step = count.saturating_sub(1).saturating_mul(4) / max;
131 1 + u8::try_from(step.min(3)).unwrap_or(3)
132 }
133
134 /// Render the contribution heatmap as an inline SVG with month and weekday
135 /// labels.
136 pub fn heatmap(&self) -> Markup {
137 // Geometry, in SVG user units.
138 let cell = 11u32;
139 let gap = 3u32;
140 let step = cell + gap;
141 let left = 30u32; // weekday-label gutter
142 let top = 18u32; // month-label strip
143 let width = left + u32::try_from(WEEKS).unwrap_or(53) * step;
144 let height = top + 7 * step;
145
146 let month_name = |m: time::Month| match m {
147 time::Month::January => "Jan",
148 time::Month::February => "Feb",
149 time::Month::March => "Mar",
150 time::Month::April => "Apr",
151 time::Month::May => "May",
152 time::Month::June => "Jun",
153 time::Month::July => "Jul",
154 time::Month::August => "Aug",
155 time::Month::September => "Sep",
156 time::Month::October => "Oct",
157 time::Month::November => "Nov",
158 time::Month::December => "Dec",
159 };
160
161 let mut body = String::new();
162 // Month labels: at the first column whose Monday introduces a new month.
163 let mut prev_month: Option<time::Month> = None;
164 for week in 0..WEEKS {
165 let wk = u32::try_from(week).unwrap_or(0);
166 let col_date = self.start + Duration::days(i64::from(wk) * 7);
167 let m = col_date.month();
168 if prev_month != Some(m) {
169 prev_month = Some(m);
170 let x = left + wk * step;
171 let _ = write!(
172 body,
173 r#"<text x="{x}" y="12" class="hm-label">{}</text>"#,
174 month_name(m)
175 );
176 }
177 }
178 // Weekday labels (Mon/Wed/Fri).
179 for (row, label) in [(0u32, "Mon"), (2, "Wed"), (4, "Fri")] {
180 let y = top + row * step + cell;
181 let _ = write!(
182 body,
183 r#"<text x="0" y="{y}" class="hm-label">{label}</text>"#
184 );
185 }
186 // Cells.
187 for week in 0..WEEKS {
188 let wk = u32::try_from(week).unwrap_or(0);
189 for day in 0..7u32 {
190 let idx = week * 7 + usize::try_from(day).unwrap_or(0);
191 let count = self.counts[idx];
192 let x = left + wk * step;
193 let y = top + day * step;
194 let lvl = self.level(count);
195 let date = self.start + Duration::days(i64::try_from(idx).unwrap_or(0));
196 let _ = write!(
197 body,
198 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>"#,
199 date.year(),
200 u8::from(date.month()),
201 date.day(),
202 );
203 }
204 }
205
206 html! {
207 div class="heatmap-wrap" {
208 svg class="heatmap" viewBox=(format!("0 0 {width} {height}"))
209 width=(width) height=(height) role="img"
210 aria-label=(format!("{} contributions in the last year", self.total)) {
211 (PreEscaped(body))
212 }
213 }
214 p class="muted heatmap-caption" {
215 (self.total) " contributions in the last year"
216 }
217 }
218 }
219
220 /// Render the recent-activity commit feed.
221 pub fn feed_section(&self, now_ms: i64) -> Markup {
222 html! {
223 section class="listing" {
224 h2 { "Recent activity" }
225 @if self.feed.is_empty() {
226 div class="card" { p class="muted" { "No recent activity." } }
227 } @else {
228 ul class="activity-feed card" {
229 @for item in &self.feed {
230 @let base = format!("/{}/{}", item.owner, item.repo);
231 li class="activity-item" {
232 div class="activity-head muted" {
233 (icon(Icon::GitBranch))
234 " pushed to "
235 span class="badge" { (item.branch) }
236 " at "
237 a href=(base) { (item.owner) "/" (item.repo) }
238 span class="activity-time" { (fmt_relative(item.time_ms, now_ms)) }
239 }
240 a class="activity-commit" href=(format!("{base}/-/commit/{}", item.oid)) {
241 span class="mono commit-sha" { (item.short) }
242 span class="commit-summary" { (item.summary) }
243 }
244 }
245 }
246 }
247 }
248 }
249 }
250 }
251}
252
253/// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours
254/// ago`, falling back to an absolute date past a month.
255fn fmt_relative(ms: i64, now_ms: i64) -> String {
256 let secs = (now_ms - ms).div_euclid(1000).max(0);
257 let mins = secs / 60;
258 let hours = mins / 60;
259 let days = hours / 24;
260 if secs < 60 {
261 "just now".to_string()
262 } else if mins < 60 {
263 plural(mins, "minute")
264 } else if hours < 24 {
265 plural(hours, "hour")
266 } else if days < 30 {
267 plural(days, "day")
268 } else {
269 match OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)) {
270 Ok(dt) => format!(
271 "on {}-{:02}-{:02}",
272 dt.year(),
273 u8::from(dt.month()),
274 dt.day()
275 ),
276 Err(_) => "a while ago".to_string(),
277 }
278 }
279}
280
281/// `N unit(s) ago`, pluralizing the unit.
282fn plural(n: i64, unit: &str) -> String {
283 if n == 1 {
284 format!("1 {unit} ago")
285 } else {
286 format!("{n} {unit}s ago")
287 }
288}
crates/web/src/lib.rs +1 −0
@@ -10,6 +10,7 @@
10//! [`serve`](mod@serve_assets) (assets/health). Every interactive element works10//! [`serve`](mod@serve_assets) (assets/health). Every interactive element works
11//! without JavaScript; htmx only enhances.11//! without JavaScript; htmx only enhances.
1212
13mod activity;
13mod assets;14mod assets;
14mod avatar;15mod avatar;
15mod diff;16mod diff;
crates/web/src/pages.rs +25 −5
@@ -65,10 +65,11 @@ pub async fn home(
65 MaybeUser(user): MaybeUser,65 MaybeUser(user): MaybeUser,
66 jar: CookieJar,66 jar: CookieJar,
67 uri: Uri,67 uri: Uri,
68 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
68) -> AppResult<Response> {69) -> AppResult<Response> {
69 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());70 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());
70 if let Some(u) = user {71 if let Some(u) = user {
71 let body = dashboard_body(&state, &u).await?;72 let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default()).await?;
72 Ok((jar, page(&chrome, "Dashboard", body)).into_response())73 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
73 } else {74 } else {
74 let body = explore_repos_body(&state, "").await?;75 let body = explore_repos_body(&state, "").await?;
@@ -211,16 +212,35 @@ fn user_card(users: &[User]) -> Markup {
211 }212 }
212}213}
213214
214/// The dashboard: a greeting and the viewer's own repositories.215/// The dashboard: the viewer's contribution activity and recent commits, beside
215async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {216/// a filterable list of their repositories.
217async fn dashboard_body(state: &AppState, user: &User, q: &str) -> AppResult<Markup> {
218 let now = now_ms();
219 let activity = crate::activity::compute(state, user, now).await?;
220
216 let mine = state.store.repos_by_owner(&user.id).await?;221 let mine = state.store.repos_by_owner(&user.id).await?;
222 let needle = q.trim().to_lowercase();
217 let entries: Vec<RepoEntry> = mine223 let entries: Vec<RepoEntry> = mine
218 .iter()224 .iter()
225 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
219 .map(|r| RepoEntry::owned(&user.username, r))226 .map(|r| RepoEntry::owned(&user.username, r))
220 .collect();227 .collect();
228
221 Ok(html! {229 Ok(html! {
222 h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }230 div class="dashboard" {
223 (repo_card("Your repositories", "You have no repositories yet.", &entries))231 div class="dashboard-main" {
232 (activity.heatmap())
233 (activity.feed_section(now))
234 }
235 aside class="dashboard-side" {
236 form class="search-row" method="get" {
237 input type="search" name="q" value=(q)
238 placeholder="Search repositories…" aria-label="Search repositories";
239 button class="btn btn-primary" type="submit" { "Search" }
240 }
241 (repo_card("Repositories", "You have no repositories yet.", &entries))
242 }
243 }
224 })244 })
225}245}
226246