irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Heatmap: GitHub-style quartile shading with every level styled, author-timezone dates, year switcher, instant tooltips site-wide; credit past commits on email verification; admin rebuild-contributions

huncholanehuncholaneauthored
parent 5f6024ccommit 6c74cf6386ccb3b20022a67cb0f4e58d2fbbbf65Browse files

8 files changed, +359 -77

+12-4backend/src/git.rs
@@ -92,6 +92,9 @@ pub struct Commit {
9292 pub author_name: String,
9393 pub author_email: String,
9494 pub authored_at: DateTime<Utc>,
95+ /// The calendar day in the author's own timezone, which is how GitHub
96+ /// (and the heatmap) date a commit.
97+ pub authored_day: chrono::NaiveDate,
9598 pub committer_name: String,
9699 pub committed_at: DateTime<Utc>,
97100 pub subject: String,
@@ -104,7 +107,7 @@ impl Commit {
104107 }
105108 }
106109
107-const COMMIT_FORMAT: &str = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ct%x00%s%x00%b%x1e";
110+const COMMIT_FORMAT: &str = "%H%x00%P%x00%an%x00%ae%x00%at%x00%aI%x00%cn%x00%ct%x00%s%x00%b%x1e";
108111
109112 fn parse_commits(output: &[u8]) -> Vec<Commit> {
110113 String::from_utf8_lossy(output)
@@ -114,17 +117,19 @@ fn parse_commits(output: &[u8]) -> Vec<Commit> {
114117 if record.is_empty() {
115118 return None;
116119 }
117- let mut f = record.splitn(9, '\0');
120+ let mut f = record.splitn(10, '\0');
118121 let sha = f.next()?.to_string();
119122 let parents = f.next()?.split_whitespace().map(String::from).collect();
120123 let author_name = f.next()?.to_string();
121124 let author_email = f.next()?.to_string();
122125 let authored_at = timestamp(f.next()?);
126+ let local = f.next()?;
127+ let authored_day = chrono::NaiveDate::parse_from_str(local.get(..10).unwrap_or(""), "%Y-%m-%d").unwrap_or_else(|_| authored_at.date_naive());
123128 let committer_name = f.next()?.to_string();
124129 let committed_at = timestamp(f.next()?);
125130 let subject = f.next()?.to_string();
126131 let body = f.next().unwrap_or("").trim_end().to_string();
127- Some(Commit { sha, parents, author_name, author_email, authored_at, committer_name, committed_at, subject, body })
132+ Some(Commit { sha, parents, author_name, author_email, authored_at, authored_day, committer_name, committed_at, subject, body })
128133 })
129134 .collect()
130135 }
@@ -440,12 +445,15 @@ mod tests {
440445
441446 #[test]
442447 fn parses_commit_records() {
443- let raw = b"abc\0p1 p2\0Ann\0ann@x.io\01700000000\0Bob\01700000100\0Subject line\0Body text\n\x1e";
448+ // 01:00 UTC on Nov 15 is 20:00 on Nov 14 for an author at UTC-5.
449+ let raw = b"abc\0p1 p2\0Ann\0ann@x.io\01700010000\02023-11-14T20:00:00-05:00\0Bob\01700010100\0Subject line\0Body text\n\x1e";
444450 let commits = parse_commits(raw);
445451 assert_eq!(commits.len(), 1);
446452 assert_eq!(commits[0].parents, vec!["p1", "p2"]);
447453 assert_eq!(commits[0].author_email, "ann@x.io");
448454 assert_eq!(commits[0].subject, "Subject line");
449455 assert_eq!(commits[0].body, "Body text");
456+ assert_eq!(commits[0].authored_at.date_naive().to_string(), "2023-11-15");
457+ assert_eq!(commits[0].authored_day.to_string(), "2023-11-14");
450458 }
451459 }
+20-0backend/src/main.rs
@@ -80,6 +80,8 @@ enum AdminCommand {
8080 CheckStorage,
8181 /// Send a test email through the configured SMTP server.
8282 TestEmail { to: String },
83+ /// Recount every repository's heatmap contributions from git history.
84+ RebuildContributions,
8385 /// Publish a CLI build to R2 so `ig upgrade` and install.sh serve it.
8486 PublishCli {
8587 /// Path to the built `ig` binary.
@@ -177,6 +179,24 @@ async fn admin(command: AdminCommand) -> anyhow::Result<()> {
177179 AdminCommand::Promote { username } => set_admin(&db, &username, true).await,
178180 AdminCommand::Demote { username } => set_admin(&db, &username, false).await,
179181 AdminCommand::CheckStorage => check_storage(&config).await,
182+ AdminCommand::RebuildContributions => {
183+ let state = AppState {
184+ storage: Storage::new(&config.r2)?,
185+ http: reqwest::Client::new(),
186+ config: Arc::new(config),
187+ db,
188+ reload_tx: broadcast::channel::<()>(1).0,
189+ };
190+ let repos: Vec<models::Repo> = sqlx::query_as(concat!(repo_select!(), " where not r.is_empty order by r.id")).fetch_all(&state.db).await?;
191+ let mut total = 0;
192+ for repo in &repos {
193+ let credited = push::rebuild_contributions(&state, repo).await?;
194+ total += credited;
195+ println!("{:>6} {}", credited, repo.full_name());
196+ }
197+ println!("{total} contributions across {} repositories", repos.len());
198+ Ok(())
199+ }
180200 AdminCommand::TestEmail { to } => {
181201 anyhow::ensure!(config.smtp.is_some(), "SMTP is not configured (set SMTP_HOST, SMTP_USERNAME and SMTP_PASSWORD)");
182202 mail::send_with(
+51-1backend/src/push.rs
@@ -156,7 +156,7 @@ pub async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old
156156 if let Some(user) = owners.get(&commit.author_email.to_lowercase()) {
157157 shas.push(commit.sha.clone());
158158 users.push(*user);
159- days.push(commit.authored_at.date_naive());
159+ days.push(commit.authored_day);
160160 }
161161 }
162162 let inserted = sqlx::query(
@@ -175,6 +175,56 @@ pub async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old
175175 Ok(())
176176 }
177177
178+/// Recounts a repository's heatmap contributions from scratch: every
179+/// commit on the default branch whose author email is verified by someone.
180+/// Used after email changes and by `irongit admin rebuild-contributions`.
181+pub async fn rebuild_contributions(state: &AppState, repo: &Repo) -> anyhow::Result<u64> {
182+ let git = Git::new(repo.disk_path(&state.config));
183+ let Some(head) = git.resolve_commit(&format!("refs/heads/{}", repo.default_branch)).await? else {
184+ return Ok(0);
185+ };
186+ sqlx::query("delete from contribution_commits where repo_id = $1").bind(repo.id).execute(&state.db).await?;
187+ record_contributions(state, repo.id, &git, ZERO_SHA, &head).await?;
188+ let count: i64 = sqlx::query_scalar("select count(*) from contribution_commits where repo_id = $1").bind(repo.id).fetch_one(&state.db).await?;
189+ Ok(count as u64)
190+}
191+
192+/// Credits a user for past commits made with newly verified emails, across
193+/// every repository's default branch, the way GitHub does retroactively.
194+pub async fn credit_emails(state: &AppState, user_id: i64, emails: &[String]) -> anyhow::Result<u64> {
195+ let wanted: Vec<String> = emails.iter().map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()).collect();
196+ if wanted.is_empty() {
197+ return Ok(0);
198+ }
199+ let repos: Vec<Repo> = sqlx::query_as(concat!(crate::repo_select!(), " where not r.is_empty")).fetch_all(&state.db).await?;
200+ let mut credited = 0;
201+ for repo in repos {
202+ let git = Git::new(repo.disk_path(&state.config));
203+ let Some(head) = git.resolve_commit(&format!("refs/heads/{}", repo.default_branch)).await? else { continue };
204+ let commits = git.new_commits(ZERO_SHA, &head, MAX_CONTRIBUTION_COMMITS).await.unwrap_or_default();
205+ let mine: Vec<&crate::git::Commit> = commits.iter().filter(|c| wanted.contains(&c.author_email.to_lowercase())).collect();
206+ if mine.is_empty() {
207+ continue;
208+ }
209+ let shas: Vec<String> = mine.iter().map(|c| c.sha.clone()).collect();
210+ let days: Vec<chrono::NaiveDate> = mine.iter().map(|c| c.authored_day).collect();
211+ credited += sqlx::query(
212+ "insert into contribution_commits (repo_id, sha, user_id, day)
213+ select $1, sha, $2, day from unnest($3::text[], $4::date[]) as t(sha, day)
214+ on conflict do nothing",
215+ )
216+ .bind(repo.id)
217+ .bind(user_id)
218+ .bind(&shas)
219+ .bind(&days)
220+ .execute(&state.db)
221+ .await?
222+ .rows_affected();
223+ }
224+ tracing::info!(user_id, credited, "past commits credited for verified emails");
225+ Ok(credited)
226+}
227+
178228 #[cfg(test)]
179229 mod tests {
180230 use super::*;
+7-0backend/src/web/account.rs
@@ -342,6 +342,13 @@ pub async fn verify_email(ctx: Ctx, Query(query): Query<VerifyQuery>) -> AppResu
342342 let body = match &verified {
343343 Some((user_id, email)) => {
344344 audit(&ctx.state.db, Some(*user_id), "email.verify", email, json!({}), None).await;
345+ // Older commits made with this address count now too.
346+ let (state, user, address) = (ctx.state.clone(), *user_id, email.clone());
347+ tokio::spawn(async move {
348+ if let Err(error) = crate::push::credit_emails(&state, user, &[address]).await {
349+ tracing::warn!(?error, "crediting past commits failed");
350+ }
351+ });
345352 analytics::track(&ctx.state, "email_verified", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/emails/verify", json!({}));
346353 html! { (ui::alert_ok(Some(&format!("{email} is verified. Commits made with it now count on your profile.")))) a href="/settings/emails" { "Back to email settings" } }
347354 }
+113-46backend/src/web/heatmap.rs
@@ -1,53 +1,86 @@
1-//! The contribution heatmap: 53 weeks by 7 days of commits that landed on
2-//! default branches, drawn as SVG with forge-heat colors.
1+//! The contribution heatmap: weeks by days of commits that landed on
2+//! default branches, drawn as SVG with forge-heat colors. Laid out and shaded
3+//! the way GitHub does it, so an imported history looks the same here.
34
45 use std::collections::HashMap;
56
67 use chrono::{Datelike, Duration, NaiveDate};
78 use maud::{Markup, html};
89
10+/// Written out in full so Tailwind, which scans this file, generates every
11+/// class; a name built at runtime ("fill-heat-" + level) would have no CSS.
12+const LEVEL_CLASSES: [&str; 5] = ["fill-heat-0", "fill-heat-1", "fill-heat-2", "fill-heat-3", "fill-heat-4"];
13+
914 const CELL: i64 = 11;
1015 const GAP: i64 = 3;
1116 const LEFT: i64 = 28;
1217 const TOP: i64 = 16;
1318
14-/// Per-day counts for the year ending today (UTC).
19+/// Per-day counts over a date range: the rolling year ending today, or one
20+/// calendar year.
1521 pub struct Contributions {
1622 pub days: HashMap<NaiveDate, i64>,
23+ /// First and last day shown (inclusive).
24+ pub first: NaiveDate,
25+ pub last: NaiveDate,
26+ /// "in the last year" or "in 2025".
27+ pub label: String,
1728 pub today: NaiveDate,
1829 }
1930
2031 impl Contributions {
21- pub fn start(today: NaiveDate) -> NaiveDate {
22- // Start on the Sunday 52 full weeks back so columns are whole weeks.
23- let back = today - Duration::days(364);
24- back - Duration::days(back.weekday().num_days_from_sunday() as i64)
32+ /// The rolling year GitHub shows by default, ending today.
33+ pub fn rolling(today: NaiveDate) -> (NaiveDate, NaiveDate) {
34+ (today - Duration::days(364), today)
35+ }
36+
37+ /// One calendar year, cut off at today for the current one.
38+ pub fn year(year: i32, today: NaiveDate) -> (NaiveDate, NaiveDate) {
39+ let first = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid year");
40+ let last = NaiveDate::from_ymd_opt(year, 12, 31).expect("valid year").min(today);
41+ (first, last)
42+ }
43+
44+ /// Columns start on the Sunday on or before the first day.
45+ fn grid_start(&self) -> NaiveDate {
46+ self.first - Duration::days(self.first.weekday().num_days_from_sunday() as i64)
47+ }
48+
49+ fn in_range(&self, day: &NaiveDate) -> bool {
50+ *day >= self.first && *day <= self.last
2551 }
2652
2753 pub fn total(&self) -> i64 {
28- self.days.values().sum()
54+ self.days.iter().filter(|(d, _)| self.in_range(d)).map(|(_, c)| *c).sum()
2955 }
3056
31- /// Thresholds for levels 1..4 relative to the busiest day, so a light
32- /// committer still gets a readable range of colors.
33- fn level(&self, count: i64, max: i64) -> u8 {
34- if count <= 0 {
35- return 0;
57+ /// GitHub's shading: quartiles of the days that have any activity, so a
58+ /// few huge days do not wash out the rest of the year.
59+ fn thresholds(&self) -> [i64; 3] {
60+ let mut counts: Vec<i64> = self.days.iter().filter(|(d, c)| self.in_range(d) && **c > 0).map(|(_, c)| *c).collect();
61+ if counts.is_empty() {
62+ return [1, 1, 1];
3663 }
37- let ratio = count as f64 / max.max(1) as f64;
38- match ratio {
39- r if r > 0.75 => 4,
40- r if r > 0.5 => 3,
41- r if r > 0.25 => 2,
42- _ => 1,
64+ counts.sort_unstable();
65+ let at = |q: f64| counts[((counts.len() - 1) as f64 * q).round() as usize];
66+ [at(0.25), at(0.5), at(0.75)]
67+ }
68+
69+ fn level(count: i64, thresholds: &[i64; 3]) -> u8 {
70+ match count {
71+ c if c <= 0 => 0,
72+ c if c <= thresholds[0] => 1,
73+ c if c <= thresholds[1] => 2,
74+ c if c <= thresholds[2] => 3,
75+ _ => 4,
4376 }
4477 }
4578
4679 pub fn longest_streak(&self) -> i64 {
4780 let mut best = 0;
4881 let mut run = 0;
49- let mut day = Self::start(self.today);
50- while day <= self.today {
82+ let mut day = self.first;
83+ while day <= self.last {
5184 if self.days.get(&day).copied().unwrap_or(0) > 0 {
5285 run += 1;
5386 best = best.max(run);
@@ -75,37 +108,43 @@ impl Contributions {
75108 }
76109
77110 pub fn render(data: &Contributions) -> Markup {
78- let start = Contributions::start(data.today);
79- let max = data.days.iter().filter(|(d, _)| **d >= start).map(|(_, c)| *c).max().unwrap_or(0);
80- let weeks = ((data.today - start).num_days() / 7) + 1;
111+ let start = data.grid_start();
112+ let thresholds = data.thresholds();
113+ let weeks = ((data.last - start).num_days() / 7) + 1;
81114 let width = LEFT + weeks * (CELL + GAP);
82115 let height = TOP + 7 * (CELL + GAP);
83116
84- let mut cells = Vec::new();
117+ // A month label goes over the first column whose Sunday is in that month,
118+ // except when it would crowd the right edge.
85119 let mut months = Vec::new();
86- let mut last_month = None;
120+ let mut labelled = None;
121+ for week in 0..weeks {
122+ let sunday = start + Duration::days(week * 7);
123+ let day = sunday.max(data.first);
124+ if labelled != Some((day.year(), day.month())) && (day.day() <= 7 || week == 0) {
125+ if week < weeks - 2 || months.is_empty() {
126+ months.push((week, day.format("%b").to_string()));
127+ }
128+ labelled = Some((day.year(), day.month()));
129+ }
130+ }
131+
132+ let mut cells = Vec::new();
87133 for week in 0..weeks {
88134 for weekday in 0..7 {
89135 let day = start + Duration::days(week * 7 + weekday);
90- if day > data.today {
91- break;
92- }
93- if weekday == 0 && last_month != Some(day.month()) && day.day() <= 7 {
94- if week < weeks - 2 {
95- months.push((week, day.format("%b").to_string()));
96- }
97- last_month = Some(day.month());
136+ if day < data.first || day > data.last {
137+ continue;
98138 }
99139 let count = data.days.get(&day).copied().unwrap_or(0);
100- cells.push((week, weekday, day, count, data.level(count, max)));
140+ cells.push((week, weekday, day, count, Contributions::level(count, &thresholds)));
101141 }
102142 }
103143
104144 html! {
105145 div class="overflow-x-auto" data-scroll-end {
106- // Fills the box on wide screens, scrolls (from the newest end) on phones.
107- svg viewBox={ "0 0 " (width) " " (height) } class="block h-auto w-full" style={ "min-width:" (width) "px;max-width:" (width * 3 / 2) "px" } role="img"
108- aria-label={ (data.total()) " contributions in the last year" } {
146+ svg viewBox={ "0 0 " (width) " " (height) } class="block h-auto w-full" style={ "min-width:" (width) "px;max-width:" (width * 3 / 2) "px" }
147+ role="img" aria-label={ (data.total()) " contributions " (data.label) } {
109148 @for (week, label) in &months {
110149 text x=(LEFT + week * (CELL + GAP)) y="10" class="fill-ink-dim" font-size="10" { (label) }
111150 }
@@ -114,16 +153,14 @@ pub fn render(data: &Contributions) -> Markup {
114153 }
115154 @for (week, weekday, day, count, level) in &cells {
116155 rect x=(LEFT + week * (CELL + GAP)) y=(TOP + weekday * (CELL + GAP)) width=(CELL) height=(CELL) rx="2"
117- class={ "fill-heat-" (level) } data-count=(count) data-day=(day.to_string()) {
118- title { (tooltip(*count, *day)) }
119- }
156+ class={ "heat-cell " (LEVEL_CLASSES[*level as usize]) } data-tip=(tooltip(*count, *day)) {}
120157 }
121158 }
122159 }
123160 div class="mt-1 flex items-center justify-end gap-1 text-[11px] text-ink-faint" {
124161 "Less"
125162 @for level in 0..5 {
126- svg width="10" height="10" { rect width="10" height="10" rx="2" class={ "fill-heat-" (level) } {} }
163+ svg width="10" height="10" { rect width="10" height="10" rx="2" class=(LEVEL_CLASSES[level]) {} }
127164 }
128165 "More"
129166 }
@@ -131,7 +168,7 @@ pub fn render(data: &Contributions) -> Markup {
131168 }
132169
133170 fn tooltip(count: i64, day: NaiveDate) -> String {
134- let when = day.format("%a, %b %-d, %Y");
171+ let when = day.format("%B %-d, %Y");
135172 match count {
136173 0 => format!("No contributions on {when}"),
137174 1 => format!("1 contribution on {when}"),
@@ -144,17 +181,47 @@ mod tests {
144181 use super::*;
145182 use chrono::Weekday;
146183
184+ fn data(days: HashMap<NaiveDate, i64>, today: NaiveDate) -> Contributions {
185+ let (first, last) = Contributions::rolling(today);
186+ Contributions { days, first, last, label: "in the last year".into(), today }
187+ }
188+
147189 #[test]
148190 fn grid_starts_on_sunday_and_streaks_count() {
149191 let today = NaiveDate::from_ymd_opt(2026, 10, 8).unwrap();
150- assert_eq!(Contributions::start(today).weekday(), Weekday::Sun);
151192 let days = HashMap::from([(today, 2), (today - Duration::days(1), 1), (today - Duration::days(3), 5)]);
152- let data = Contributions { days, today };
193+ let data = data(days, today);
194+ assert_eq!(data.grid_start().weekday(), Weekday::Sun);
153195 assert_eq!(data.current_streak(), 2);
154196 assert_eq!(data.longest_streak(), 2);
155197 assert_eq!(data.total(), 8);
156198 let svg = render(&data).0;
157199 assert!(svg.contains("fill-heat-4"));
158- assert!(svg.contains("5 contributions on"));
200+ assert!(svg.contains("5 contributions on October 5, 2026"));
201+ }
202+
203+ #[test]
204+ fn quartiles_keep_quiet_days_visible() {
205+ // One enormous day must not push everything else to the faintest shade.
206+ let today = NaiveDate::from_ymd_opt(2026, 10, 8).unwrap();
207+ let mut days = HashMap::new();
208+ for i in 0..40 {
209+ days.insert(today - Duration::days(i * 3), 1 + i % 8);
210+ }
211+ days.insert(today - Duration::days(1), 178);
212+ let data = data(days, today);
213+ let t = data.thresholds();
214+ assert!(t[0] >= 1 && t[0] < t[2], "{t:?}");
215+ assert_eq!(Contributions::level(178, &t), 4);
216+ assert_eq!(Contributions::level(t[1], &t), 2);
217+ }
218+
219+ #[test]
220+ fn year_view_is_bounded() {
221+ let today = NaiveDate::from_ymd_opt(2026, 10, 8).unwrap();
222+ let (first, last) = Contributions::year(2025, today);
223+ assert_eq!((first.to_string(), last.to_string()), ("2025-01-01".to_string(), "2025-12-31".to_string()));
224+ let (_, last) = Contributions::year(2026, today);
225+ assert_eq!(last, today);
159226 }
160227 }
+54-10backend/src/web/profile.rs
@@ -7,7 +7,7 @@ use axum::{
77 extract::{Path, Query, Request},
88 response::{IntoResponse, Response},
99 };
10-use chrono::{DateTime, NaiveDate, Utc};
10+use chrono::{DateTime, Datelike, NaiveDate, Utc};
1111 use maud::{Markup, html};
1212 use serde::Deserialize;
1313
@@ -32,6 +32,8 @@ pub struct ProfileQuery {
3232 tab: Option<String>,
3333 q: Option<String>,
3434 welcome: Option<String>,
35+ /// Heatmap year; the rolling last year when absent.
36+ year: Option<i32>,
3537 }
3638
3739 pub async fn show(ctx: Ctx, Path(name): Path<String>, Query(query): Query<ProfileQuery>, request: Request) -> AppResult<Response> {
@@ -101,9 +103,20 @@ async fn counts(ctx: &Ctx, owner_id: i64) -> AppResult<(i64, i64)> {
101103 Ok((repos, packages))
102104 }
103105
104-pub async fn contributions(ctx: &Ctx, user_id: i64) -> AppResult<Contributions> {
106+/// Days with contributions the viewer may see, for the rolling year or one
107+/// calendar year, plus the years that have any (for the year switcher).
108+pub async fn contributions(ctx: &Ctx, user_id: i64, year: Option<i32>) -> AppResult<(Contributions, Vec<i32>)> {
105109 let today = Utc::now().date_naive();
106- let start = Contributions::start(today);
110+ let (first, last, label) = match year.filter(|y| (2005..=today.year()).contains(y)) {
111+ Some(y) => {
112+ let (first, last) = Contributions::year(y, today);
113+ (first, last, format!("in {y}"))
114+ }
115+ None => {
116+ let (first, last) = Contributions::rolling(today);
117+ (first, last, "in the last year".to_string())
118+ }
119+ };
107120 let show_private: bool = sqlx::query_scalar("select show_private_contributions from users where account_id = $1")
108121 .bind(user_id)
109122 .fetch_one(&ctx.state.db)
@@ -112,18 +125,34 @@ pub async fn contributions(ctx: &Ctx, user_id: i64) -> AppResult<Contributions>
112125 let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo);
113126 let rows: Vec<(NaiveDate, i64)> = sqlx::query_as(concat!(
114127 "select c.day, count(*)::bigint from contribution_commits c join repos r on r.id = c.repo_id
115- where c.user_id = $3 and c.day >= $4 and ($5 or ",
128+ where c.user_id = $3 and c.day between $4 and $5 and ($6 or ",
116129 visible_repos!(),
117130 ") group by c.day"
118131 ))
119132 .bind(viewer_id)
120133 .bind(admin)
121134 .bind(user_id)
122- .bind(start)
135+ .bind(first)
136+ .bind(last)
123137 .bind(include_all)
124138 .fetch_all(&ctx.state.db)
125139 .await?;
126- Ok(Contributions { days: rows.into_iter().collect(), today })
140+ let earliest: Option<NaiveDate> = sqlx::query_scalar(concat!(
141+ "select min(c.day) from contribution_commits c join repos r on r.id = c.repo_id where c.user_id = $3 and ($4 or ",
142+ visible_repos!(),
143+ ")"
144+ ))
145+ .bind(viewer_id)
146+ .bind(admin)
147+ .bind(user_id)
148+ .bind(include_all)
149+ .fetch_one(&ctx.state.db)
150+ .await?;
151+ let years: Vec<i32> = match earliest {
152+ Some(day) => (day.year().max(today.year() - 15)..=today.year()).rev().collect(),
153+ None => vec![today.year()],
154+ };
155+ Ok((Contributions { days: rows.into_iter().collect(), first, last, label, today }, years))
127156 }
128157
129158 pub struct Activity {
@@ -272,7 +301,8 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes
272301 }
273302 }
274303 _ => {
275- let data = contributions(ctx, account.id).await?;
304+ let (data, years) = contributions(ctx, account.id, query.year).await?;
305+ let selected_year = query.year.unwrap_or_else(|| data.today.year());
276306 let popular = owned_repos(ctx, account.id, None, 6).await?;
277307 let feed = activity(ctx, Some(account.id), None, 25).await?;
278308 html! {
@@ -293,10 +323,15 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes
293323 }
294324 }
295325 div class="mb-2 flex flex-wrap items-baseline gap-x-4" {
296- h2 class="text-[13px] font-semibold" { (data.total()) " contributions in the last year" }
297- span class="text-xs text-ink-faint" { "Current streak " (data.current_streak()) " days, longest " (data.longest_streak()) }
326+ h2 class="text-[13px] font-semibold" { (data.total()) " contributions " (data.label) }
327+ @if query.year.is_none() {
328+ span class="text-xs text-ink-faint" { "Current streak " (data.current_streak()) " days, longest " (data.longest_streak()) }
329+ } @else {
330+ span class="text-xs text-ink-faint" { "Longest streak " (data.longest_streak()) " days" }
331+ }
298332 }
299- div class="box mb-5 p-3" {
333+ div class="mb-5 flex flex-col-reverse gap-2 lg:flex-row lg:items-start" {
334+ div class="box min-w-0 flex-1 p-3" {
300335 (heatmap::render(&data))
301336 @if is_self {
302337 p class="mt-2 text-[11px] text-ink-faint" {
@@ -305,6 +340,15 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes
305340 }
306341 }
307342 }
343+ nav class="flex gap-1 overflow-x-auto lg:w-[84px] lg:flex-col" aria-label="Contribution year" {
344+ @for year in &years {
345+ a href={ "/" (account.name) "?year=" (year) } aria-current=[(*year == selected_year).then_some("true")]
346+ class={ "rounded-[4px] px-3 py-1.5 text-[13px] no-underline hover:no-underline "
347+ @if *year == selected_year { "bg-accent font-semibold text-accent-ink" } @else { "text-ink-dim hover:bg-surface-hover" } }
348+ data-track="heatmap_year_clicked" { (year) }
349+ }
350+ }
351+ }
308352 h2 class="mb-2 text-[13px] font-semibold" { "Activity" }
309353 div class="box" { (activity_list(&feed, false)) }
310354 }
+59-0frontend/src/scripts/app.ts
@@ -243,7 +243,66 @@ function initScrollEnd() {
243243 });
244244 }
245245
246+// --- Fast tooltips -------------------------------------------------------
247+// Any element with data-tip (or a title, which is moved to data-tip so the
248+// slow native tooltip never appears) shows one shared tooltip instantly.
249+
250+function initTooltips() {
251+ const tip = document.createElement("div");
252+ tip.className = "ig-tip";
253+ tip.setAttribute("role", "tooltip");
254+ document.body.appendChild(tip);
255+ let current: Element | null = null;
256+
257+ const target = (node: EventTarget | null) => {
258+ const el = (node as Element)?.closest?.("[data-tip], [title]");
259+ if (!el) return null;
260+ if (el.hasAttribute("title")) {
261+ const text = el.getAttribute("title") ?? "";
262+ el.removeAttribute("title");
263+ if (!el.hasAttribute("data-tip") && text) el.setAttribute("data-tip", text);
264+ }
265+ return el.getAttribute("data-tip") ? el : null;
266+ };
267+
268+ const show = (el: Element) => {
269+ current = el;
270+ tip.textContent = el.getAttribute("data-tip");
271+ tip.style.left = "0px";
272+ tip.style.top = "0px";
273+ tip.setAttribute("data-show", "");
274+ const box = el.getBoundingClientRect();
275+ const own = tip.getBoundingClientRect();
276+ let top = box.top - own.height - 6;
277+ if (top < 4) top = box.bottom + 6;
278+ const left = Math.min(Math.max(4, box.left + box.width / 2 - own.width / 2), window.innerWidth - own.width - 4);
279+ tip.style.left = `${left}px`;
280+ tip.style.top = `${top}px`;
281+ };
282+
283+ const hide = () => {
284+ current = null;
285+ tip.removeAttribute("data-show");
286+ };
287+
288+ document.addEventListener("pointerover", (event) => {
289+ const el = target(event.target);
290+ if (el && el !== current) show(el);
291+ else if (!el && current) hide();
292+ });
293+ document.addEventListener("pointerout", (event) => {
294+ if (current && !current.contains(event.relatedTarget as Node)) hide();
295+ });
296+ document.addEventListener("focusin", (event) => {
297+ const el = target(event.target);
298+ if (el) show(el);
299+ });
300+ document.addEventListener("focusout", hide);
301+ window.addEventListener("scroll", hide, { passive: true, capture: true });
302+}
303+
246304 function init() {
305+ initTooltips();
247306 initScrollEnd();
248307 initLogging();
249308 initAnalytics();
+43-16frontend/src/styles/global.css
@@ -25,12 +25,13 @@
2525 --color-danger: #f85149;
2626 --color-warn: #d29922;
2727
28- /* Contribution heatmap: forge heat, cold to white-hot. */
29- --color-heat-0: #161c27;
30- --color-heat-1: #4a2a12;
31- --color-heat-2: #8a4413;
32- --color-heat-3: #d0661a;
33- --color-heat-4: #ff9a3c;
28+ /* Contribution heatmap: forge heat, cold to white-hot. Even steps, and
29+ the first one clearly visible against an empty day. */
30+ --color-heat-0: #171d29;
31+ --color-heat-1: #6b3410;
32+ --color-heat-2: #a84e14;
33+ --color-heat-3: #e46f1c;
34+ --color-heat-4: #ffa552;
3435
3536 --radius-card: 4px;
3637 }
@@ -48,11 +49,11 @@
4849 --color-ink-dim: #556070;
4950 --color-ink-faint: #8590a0;
5051 --color-accent: #2563eb;
51- --color-heat-0: #eceff3;
52- --color-heat-1: #ffd8b0;
53- --color-heat-2: #ffa860;
54- --color-heat-3: #f07a1a;
55- --color-heat-4: #b44c00;
52+ --color-heat-0: #ebedf0;
53+ --color-heat-1: #ffcf9e;
54+ --color-heat-2: #ff9f4f;
55+ --color-heat-3: #e8700f;
56+ --color-heat-4: #a84700;
5657 color-scheme: light;
5758 }
5859 }
@@ -67,11 +68,11 @@
6768 --color-ink-dim: #556070;
6869 --color-ink-faint: #8590a0;
6970 --color-accent: #2563eb;
70- --color-heat-0: #eceff3;
71- --color-heat-1: #ffd8b0;
72- --color-heat-2: #ffa860;
73- --color-heat-3: #f07a1a;
74- --color-heat-4: #b44c00;
71+ --color-heat-0: #ebedf0;
72+ --color-heat-1: #ffcf9e;
73+ --color-heat-2: #ff9f4f;
74+ --color-heat-3: #e8700f;
75+ --color-heat-4: #a84700;
7576 color-scheme: light;
7677 }
7778 :root {
@@ -313,3 +314,29 @@
313314 margin-left: 44px;
314315 }
315316 }
317+
318+/* Fast tooltips (app.ts): one shared element, shown instantly. */
319+.ig-tip {
320+ position: fixed;
321+ z-index: 100;
322+ pointer-events: none;
323+ max-width: min(320px, calc(100vw - 16px));
324+ padding: 4px 8px;
325+ border-radius: 4px;
326+ background: #1f2530;
327+ border: 1px solid #3a4454;
328+ color: #f0f3f6;
329+ font-size: 12px;
330+ line-height: 1.35;
331+ white-space: pre-line;
332+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.35);
333+ opacity: 0;
334+ transition: opacity 60ms linear;
335+}
336+.ig-tip[data-show] {
337+ opacity: 1;
338+}
339+.heat-cell:hover {
340+ stroke: var(--color-ink-dim);
341+ stroke-width: 1;
342+}