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
8 files changed, +359 -77
+12-4backend/src/git.rs
| @@ -92,6 +92,9 @@ pub struct Commit { | ||
| 92 | 92 | pub author_name: String, |
| 93 | 93 | pub author_email: String, |
| 94 | 94 | 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, | |
| 95 | 98 | pub committer_name: String, |
| 96 | 99 | pub committed_at: DateTime<Utc>, |
| 97 | 100 | pub subject: String, |
| @@ -104,7 +107,7 @@ impl Commit { | ||
| 104 | 107 | } |
| 105 | 108 | } |
| 106 | 109 | |
| 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"; | |
| 108 | 111 | |
| 109 | 112 | fn parse_commits(output: &[u8]) -> Vec<Commit> { |
| 110 | 113 | String::from_utf8_lossy(output) |
| @@ -114,17 +117,19 @@ fn parse_commits(output: &[u8]) -> Vec<Commit> { | ||
| 114 | 117 | if record.is_empty() { |
| 115 | 118 | return None; |
| 116 | 119 | } |
| 117 | - let mut f = record.splitn(9, '\0'); | |
| 120 | + let mut f = record.splitn(10, '\0'); | |
| 118 | 121 | let sha = f.next()?.to_string(); |
| 119 | 122 | let parents = f.next()?.split_whitespace().map(String::from).collect(); |
| 120 | 123 | let author_name = f.next()?.to_string(); |
| 121 | 124 | let author_email = f.next()?.to_string(); |
| 122 | 125 | 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()); | |
| 123 | 128 | let committer_name = f.next()?.to_string(); |
| 124 | 129 | let committed_at = timestamp(f.next()?); |
| 125 | 130 | let subject = f.next()?.to_string(); |
| 126 | 131 | 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 }) | |
| 128 | 133 | }) |
| 129 | 134 | .collect() |
| 130 | 135 | } |
| @@ -440,12 +445,15 @@ mod tests { | ||
| 440 | 445 | |
| 441 | 446 | #[test] |
| 442 | 447 | 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"; | |
| 444 | 450 | let commits = parse_commits(raw); |
| 445 | 451 | assert_eq!(commits.len(), 1); |
| 446 | 452 | assert_eq!(commits[0].parents, vec!["p1", "p2"]); |
| 447 | 453 | assert_eq!(commits[0].author_email, "ann@x.io"); |
| 448 | 454 | assert_eq!(commits[0].subject, "Subject line"); |
| 449 | 455 | 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"); | |
| 450 | 458 | } |
| 451 | 459 | } |
+20-0backend/src/main.rs
| @@ -80,6 +80,8 @@ enum AdminCommand { | ||
| 80 | 80 | CheckStorage, |
| 81 | 81 | /// Send a test email through the configured SMTP server. |
| 82 | 82 | TestEmail { to: String }, |
| 83 | + /// Recount every repository's heatmap contributions from git history. | |
| 84 | + RebuildContributions, | |
| 83 | 85 | /// Publish a CLI build to R2 so `ig upgrade` and install.sh serve it. |
| 84 | 86 | PublishCli { |
| 85 | 87 | /// Path to the built `ig` binary. |
| @@ -177,6 +179,24 @@ async fn admin(command: AdminCommand) -> anyhow::Result<()> { | ||
| 177 | 179 | AdminCommand::Promote { username } => set_admin(&db, &username, true).await, |
| 178 | 180 | AdminCommand::Demote { username } => set_admin(&db, &username, false).await, |
| 179 | 181 | 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 | + } | |
| 180 | 200 | AdminCommand::TestEmail { to } => { |
| 181 | 201 | anyhow::ensure!(config.smtp.is_some(), "SMTP is not configured (set SMTP_HOST, SMTP_USERNAME and SMTP_PASSWORD)"); |
| 182 | 202 | 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 | ||
| 156 | 156 | if let Some(user) = owners.get(&commit.author_email.to_lowercase()) { |
| 157 | 157 | shas.push(commit.sha.clone()); |
| 158 | 158 | users.push(*user); |
| 159 | - days.push(commit.authored_at.date_naive()); | |
| 159 | + days.push(commit.authored_day); | |
| 160 | 160 | } |
| 161 | 161 | } |
| 162 | 162 | let inserted = sqlx::query( |
| @@ -175,6 +175,56 @@ pub async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old | ||
| 175 | 175 | Ok(()) |
| 176 | 176 | } |
| 177 | 177 | |
| 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 | + | |
| 178 | 228 | #[cfg(test)] |
| 179 | 229 | mod tests { |
| 180 | 230 | use super::*; |
+7-0backend/src/web/account.rs
| @@ -342,6 +342,13 @@ pub async fn verify_email(ctx: Ctx, Query(query): Query<VerifyQuery>) -> AppResu | ||
| 342 | 342 | let body = match &verified { |
| 343 | 343 | Some((user_id, email)) => { |
| 344 | 344 | 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 | + }); | |
| 345 | 352 | analytics::track(&ctx.state, "email_verified", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/emails/verify", json!({})); |
| 346 | 353 | 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" } } |
| 347 | 354 | } |
+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. | |
| 3 | 4 | |
| 4 | 5 | use std::collections::HashMap; |
| 5 | 6 | |
| 6 | 7 | use chrono::{Datelike, Duration, NaiveDate}; |
| 7 | 8 | use maud::{Markup, html}; |
| 8 | 9 | |
| 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 | + | |
| 9 | 14 | const CELL: i64 = 11; |
| 10 | 15 | const GAP: i64 = 3; |
| 11 | 16 | const LEFT: i64 = 28; |
| 12 | 17 | const TOP: i64 = 16; |
| 13 | 18 | |
| 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. | |
| 15 | 21 | pub struct Contributions { |
| 16 | 22 | 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, | |
| 17 | 28 | pub today: NaiveDate, |
| 18 | 29 | } |
| 19 | 30 | |
| 20 | 31 | 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 | |
| 25 | 51 | } |
| 26 | 52 | |
| 27 | 53 | pub fn total(&self) -> i64 { |
| 28 | - self.days.values().sum() | |
| 54 | + self.days.iter().filter(|(d, _)| self.in_range(d)).map(|(_, c)| *c).sum() | |
| 29 | 55 | } |
| 30 | 56 | |
| 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]; | |
| 36 | 63 | } |
| 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, | |
| 43 | 76 | } |
| 44 | 77 | } |
| 45 | 78 | |
| 46 | 79 | pub fn longest_streak(&self) -> i64 { |
| 47 | 80 | let mut best = 0; |
| 48 | 81 | 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 { | |
| 51 | 84 | if self.days.get(&day).copied().unwrap_or(0) > 0 { |
| 52 | 85 | run += 1; |
| 53 | 86 | best = best.max(run); |
| @@ -75,37 +108,43 @@ impl Contributions { | ||
| 75 | 108 | } |
| 76 | 109 | |
| 77 | 110 | 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; | |
| 81 | 114 | let width = LEFT + weeks * (CELL + GAP); |
| 82 | 115 | let height = TOP + 7 * (CELL + GAP); |
| 83 | 116 | |
| 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. | |
| 85 | 119 | 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(); | |
| 87 | 133 | for week in 0..weeks { |
| 88 | 134 | for weekday in 0..7 { |
| 89 | 135 | 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; | |
| 98 | 138 | } |
| 99 | 139 | 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))); | |
| 101 | 141 | } |
| 102 | 142 | } |
| 103 | 143 | |
| 104 | 144 | html! { |
| 105 | 145 | 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) } { | |
| 109 | 148 | @for (week, label) in &months { |
| 110 | 149 | text x=(LEFT + week * (CELL + GAP)) y="10" class="fill-ink-dim" font-size="10" { (label) } |
| 111 | 150 | } |
| @@ -114,16 +153,14 @@ pub fn render(data: &Contributions) -> Markup { | ||
| 114 | 153 | } |
| 115 | 154 | @for (week, weekday, day, count, level) in &cells { |
| 116 | 155 | 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)) {} | |
| 120 | 157 | } |
| 121 | 158 | } |
| 122 | 159 | } |
| 123 | 160 | div class="mt-1 flex items-center justify-end gap-1 text-[11px] text-ink-faint" { |
| 124 | 161 | "Less" |
| 125 | 162 | @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]) {} } | |
| 127 | 164 | } |
| 128 | 165 | "More" |
| 129 | 166 | } |
| @@ -131,7 +168,7 @@ pub fn render(data: &Contributions) -> Markup { | ||
| 131 | 168 | } |
| 132 | 169 | |
| 133 | 170 | fn tooltip(count: i64, day: NaiveDate) -> String { |
| 134 | - let when = day.format("%a, %b %-d, %Y"); | |
| 171 | + let when = day.format("%B %-d, %Y"); | |
| 135 | 172 | match count { |
| 136 | 173 | 0 => format!("No contributions on {when}"), |
| 137 | 174 | 1 => format!("1 contribution on {when}"), |
| @@ -144,17 +181,47 @@ mod tests { | ||
| 144 | 181 | use super::*; |
| 145 | 182 | use chrono::Weekday; |
| 146 | 183 | |
| 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 | + | |
| 147 | 189 | #[test] |
| 148 | 190 | fn grid_starts_on_sunday_and_streaks_count() { |
| 149 | 191 | let today = NaiveDate::from_ymd_opt(2026, 10, 8).unwrap(); |
| 150 | - assert_eq!(Contributions::start(today).weekday(), Weekday::Sun); | |
| 151 | 192 | 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); | |
| 153 | 195 | assert_eq!(data.current_streak(), 2); |
| 154 | 196 | assert_eq!(data.longest_streak(), 2); |
| 155 | 197 | assert_eq!(data.total(), 8); |
| 156 | 198 | let svg = render(&data).0; |
| 157 | 199 | 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); | |
| 159 | 226 | } |
| 160 | 227 | } |
+54-10backend/src/web/profile.rs
| @@ -7,7 +7,7 @@ use axum::{ | ||
| 7 | 7 | extract::{Path, Query, Request}, |
| 8 | 8 | response::{IntoResponse, Response}, |
| 9 | 9 | }; |
| 10 | -use chrono::{DateTime, NaiveDate, Utc}; | |
| 10 | +use chrono::{DateTime, Datelike, NaiveDate, Utc}; | |
| 11 | 11 | use maud::{Markup, html}; |
| 12 | 12 | use serde::Deserialize; |
| 13 | 13 | |
| @@ -32,6 +32,8 @@ pub struct ProfileQuery { | ||
| 32 | 32 | tab: Option<String>, |
| 33 | 33 | q: Option<String>, |
| 34 | 34 | welcome: Option<String>, |
| 35 | + /// Heatmap year; the rolling last year when absent. | |
| 36 | + year: Option<i32>, | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | 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)> { | ||
| 101 | 103 | Ok((repos, packages)) |
| 102 | 104 | } |
| 103 | 105 | |
| 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>)> { | |
| 105 | 109 | 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 | + }; | |
| 107 | 120 | let show_private: bool = sqlx::query_scalar("select show_private_contributions from users where account_id = $1") |
| 108 | 121 | .bind(user_id) |
| 109 | 122 | .fetch_one(&ctx.state.db) |
| @@ -112,18 +125,34 @@ pub async fn contributions(ctx: &Ctx, user_id: i64) -> AppResult<Contributions> | ||
| 112 | 125 | let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo); |
| 113 | 126 | let rows: Vec<(NaiveDate, i64)> = sqlx::query_as(concat!( |
| 114 | 127 | "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 ", | |
| 116 | 129 | visible_repos!(), |
| 117 | 130 | ") group by c.day" |
| 118 | 131 | )) |
| 119 | 132 | .bind(viewer_id) |
| 120 | 133 | .bind(admin) |
| 121 | 134 | .bind(user_id) |
| 122 | - .bind(start) | |
| 135 | + .bind(first) | |
| 136 | + .bind(last) | |
| 123 | 137 | .bind(include_all) |
| 124 | 138 | .fetch_all(&ctx.state.db) |
| 125 | 139 | .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)) | |
| 127 | 156 | } |
| 128 | 157 | |
| 129 | 158 | pub struct Activity { |
| @@ -272,7 +301,8 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes | ||
| 272 | 301 | } |
| 273 | 302 | } |
| 274 | 303 | _ => { |
| 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()); | |
| 276 | 306 | let popular = owned_repos(ctx, account.id, None, 6).await?; |
| 277 | 307 | let feed = activity(ctx, Some(account.id), None, 25).await?; |
| 278 | 308 | html! { |
| @@ -293,10 +323,15 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes | ||
| 293 | 323 | } |
| 294 | 324 | } |
| 295 | 325 | 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 | + } | |
| 298 | 332 | } |
| 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" { | |
| 300 | 335 | (heatmap::render(&data)) |
| 301 | 336 | @if is_self { |
| 302 | 337 | p class="mt-2 text-[11px] text-ink-faint" { |
| @@ -305,6 +340,15 @@ async fn user_page(ctx: &Ctx, account: &Account, query: &ProfileQuery) -> AppRes | ||
| 305 | 340 | } |
| 306 | 341 | } |
| 307 | 342 | } |
| 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 | + } | |
| 308 | 352 | h2 class="mb-2 text-[13px] font-semibold" { "Activity" } |
| 309 | 353 | div class="box" { (activity_list(&feed, false)) } |
| 310 | 354 | } |
+59-0frontend/src/scripts/app.ts
| @@ -243,7 +243,66 @@ function initScrollEnd() { | ||
| 243 | 243 | }); |
| 244 | 244 | } |
| 245 | 245 | |
| 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 | + | |
| 246 | 304 | function init() { |
| 305 | + initTooltips(); | |
| 247 | 306 | initScrollEnd(); |
| 248 | 307 | initLogging(); |
| 249 | 308 | initAnalytics(); |
+43-16frontend/src/styles/global.css
| @@ -25,12 +25,13 @@ | ||
| 25 | 25 | --color-danger: #f85149; |
| 26 | 26 | --color-warn: #d29922; |
| 27 | 27 | |
| 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; | |
| 34 | 35 | |
| 35 | 36 | --radius-card: 4px; |
| 36 | 37 | } |
| @@ -48,11 +49,11 @@ | ||
| 48 | 49 | --color-ink-dim: #556070; |
| 49 | 50 | --color-ink-faint: #8590a0; |
| 50 | 51 | --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; | |
| 56 | 57 | color-scheme: light; |
| 57 | 58 | } |
| 58 | 59 | } |
| @@ -67,11 +68,11 @@ | ||
| 67 | 68 | --color-ink-dim: #556070; |
| 68 | 69 | --color-ink-faint: #8590a0; |
| 69 | 70 | --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; | |
| 75 | 76 | color-scheme: light; |
| 76 | 77 | } |
| 77 | 78 | :root { |
| @@ -313,3 +314,29 @@ | ||
| 313 | 314 | margin-left: 44px; |
| 314 | 315 | } |
| 315 | 316 | } |
| 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 | +} |