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 {
359359 }
360360 .badge-private {
361361 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);
362365 }
363366
364367 /* Inline search row: input grows, button sits to its right with margin. */
@@ -461,6 +464,103 @@ select:focus {
461464 font-size: 0.85em;
462465 }
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+
464564 /* ---- Avatars & profile header ---- */
465565 .avatar {
466566 border-radius: 6px;
@@ -546,6 +646,18 @@ select:focus {
546646 display: inline-flex;
547647 align-items: center;
548648 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;
549661 }
550662 .profile-joined {
551663 margin: 0;
@@ -718,34 +830,33 @@ pre.code {
718830 .repo-slug a {
719831 color: var(--fb-accent);
720832 }
721-/* Horizontal tab strips (repo sub-nav, explore sub-nav). */
833+/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
722834 .repo-tabs,
723835 .subnav {
724836 display: flex;
725- gap: 0.25rem;
837+ gap: 0.5rem;
726838 flex-wrap: wrap;
727- margin: 0.75rem 0;
728- border-bottom: 1px solid var(--fb-border);
839+ margin: 0.75rem 0 1.25rem;
729840 }
730841 .repo-tabs a,
731842 .subnav a {
732843 display: inline-flex;
733844 align-items: center;
734845 gap: 0.4rem;
735- padding: 0.5rem 0.85rem;
846+ padding: 0.4rem 0.9rem;
847+ border-radius: 999px;
736848 color: var(--fb-fg-muted);
737- border-bottom: 2px solid transparent;
738- margin-bottom: -1px;
739849 }
740850 .repo-tabs a:hover,
741851 .subnav a:hover {
742852 color: var(--fb-fg);
853+ background: var(--fb-bg-inset);
743854 text-decoration: none;
744855 }
745856 .repo-tabs a[aria-current="page"],
746857 .subnav a[aria-current="page"] {
747858 color: var(--fb-fg);
748- border-bottom-color: var(--fb-accent);
859+ background: var(--fb-bg-inset);
749860 font-weight: 600;
750861 }
751862 .subnav {
@@ -1018,11 +1129,13 @@ td.diff-empty {
10181129 display: block;
10191130 overflow-x: auto;
10201131 }
1021- /* Stack the profile columns on narrow screens. */
1022- .profile-layout {
1132+ /* Stack the profile and dashboard columns on narrow screens. */
1133+ .profile-layout,
1134+ .dashboard {
10231135 flex-direction: column;
10241136 }
1025- .profile-sidebar {
1137+ .profile-sidebar,
1138+ .dashboard-side {
10261139 width: 100%;
10271140 }
10281141 }
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+
12+use std::cmp::Reverse;
13+use std::fmt::Write as _;
14+
15+use maud::{Markup, PreEscaped, html};
16+use model::User;
17+use time::{Duration, OffsetDateTime};
18+
19+use crate::AppState;
20+use crate::error::AppResult;
21+use crate::icons::{Icon, icon};
22+use crate::repo::git_read;
23+
24+/// Weeks shown across the heatmap (a year plus the current partial week).
25+const WEEKS: usize = 53;
26+/// Commits scanned per repo on its default branch — bounds dashboard cost.
27+const SCAN_PER_REPO: usize = 400;
28+/// Recent commits shown in the activity feed.
29+const FEED_LIMIT: usize = 20;
30+
31+/// One commit in the activity feed.
32+pub 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.
50+pub 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.
62+pub 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+
123+impl 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.
255+fn 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.
282+fn 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 @@
1010 //! [`serve`](mod@serve_assets) (assets/health). Every interactive element works
1111 //! without JavaScript; htmx only enhances.
1212
13+mod activity;
1314 mod assets;
1415 mod avatar;
1516 mod diff;
crates/web/src/pages.rs +25 −5
@@ -65,10 +65,11 @@ pub async fn home(
6565 MaybeUser(user): MaybeUser,
6666 jar: CookieJar,
6767 uri: Uri,
68+ axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
6869 ) -> AppResult<Response> {
6970 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());
7071 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?;
7273 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
7374 } else {
7475 let body = explore_repos_body(&state, "").await?;
@@ -211,16 +212,35 @@ fn user_card(users: &[User]) -> Markup {
211212 }
212213 }
213214
214-/// The dashboard: a greeting and the viewer's own repositories.
215-async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {
215+/// The dashboard: the viewer's contribution activity and recent commits, beside
216+/// a filterable list of their repositories.
217+async 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+
216221 let mine = state.store.repos_by_owner(&user.id).await?;
222+ let needle = q.trim().to_lowercase();
217223 let entries: Vec<RepoEntry> = mine
218224 .iter()
225+ .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
219226 .map(|r| RepoEntry::owned(&user.username, r))
220227 .collect();
228+
221229 Ok(html! {
222- h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }
223- (repo_card("Your repositories", "You have no repositories yet.", &entries))
230+ div class="dashboard" {
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+ }
224244 })
225245 }
226246