| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 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 | |
| 25 | const WEEKS: usize = 53; |
| 26 | |
| 27 | const SCAN_PER_REPO: usize = 400; |
| 28 | |
| 29 | const FEED_LIMIT: usize = 100; |
| 30 | |
| 31 | |
| 32 | pub struct FeedItem { |
| 33 | |
| 34 | pub owner: String, |
| 35 | |
| 36 | pub repo: String, |
| 37 | |
| 38 | pub branch: String, |
| 39 | |
| 40 | pub short: String, |
| 41 | |
| 42 | pub oid: String, |
| 43 | |
| 44 | pub summary: String, |
| 45 | |
| 46 | pub time_ms: i64, |
| 47 | } |
| 48 | |
| 49 | |
| 50 | pub struct Activity { |
| 51 | |
| 52 | counts: Vec<u32>, |
| 53 | |
| 54 | total: u32, |
| 55 | |
| 56 | start: time::Date, |
| 57 | |
| 58 | feed: Vec<FeedItem>, |
| 59 | } |
| 60 | |
| 61 | |
| 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 | |
| 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 | |
| 78 | let commits = git_read(state, &repo.id, move |r| { |
| 79 | r.commits_all_branches(SCAN_PER_REPO) |
| 80 | }) |
| 81 | .await; |
| 82 | let Ok(commits) = commits else { continue }; |
| 83 | |
| 84 | for (c, branch) in commits { |
| 85 | if !c.author.email.eq_ignore_ascii_case(&email) { |
| 86 | continue; |
| 87 | } |
| 88 | |
| 89 | if let Ok(dt) = OffsetDateTime::from_unix_timestamp(c.author.time_ms.div_euclid(1000)) { |
| 90 | let offset = (dt.date() - start).whole_days(); |
| 91 | let total_days = i64::try_from(WEEKS * 7).unwrap_or(0); |
| 92 | if (0..total_days).contains(&offset) |
| 93 | && let Ok(idx) = usize::try_from(offset) |
| 94 | { |
| 95 | counts[idx] += 1; |
| 96 | } |
| 97 | } |
| 98 | feed.push(FeedItem { |
| 99 | owner: user.username.clone(), |
| 100 | repo: repo.path.clone(), |
| 101 | branch, |
| 102 | short: c.oid.short().to_string(), |
| 103 | oid: c.oid.to_string(), |
| 104 | summary: c.summary.clone(), |
| 105 | time_ms: c.author.time_ms, |
| 106 | }); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | let total = counts.iter().sum(); |
| 111 | feed.sort_by_key(|i| Reverse(i.time_ms)); |
| 112 | feed.truncate(FEED_LIMIT); |
| 113 | |
| 114 | Ok(Activity { |
| 115 | counts, |
| 116 | total, |
| 117 | start, |
| 118 | feed, |
| 119 | }) |
| 120 | } |
| 121 | |
| 122 | impl Activity { |
| 123 | |
| 124 | fn level(&self, count: u32) -> u8 { |
| 125 | if count == 0 { |
| 126 | return 0; |
| 127 | } |
| 128 | let max = self.counts.iter().copied().max().unwrap_or(1).max(1); |
| 129 | let step = count.saturating_sub(1).saturating_mul(4) / max; |
| 130 | 1 + u8::try_from(step.min(3)).unwrap_or(3) |
| 131 | } |
| 132 | |
| 133 | |
| 134 | |
| 135 | pub fn heatmap(&self) -> Markup { |
| 136 | |
| 137 | let cell = 11u32; |
| 138 | let gap = 3u32; |
| 139 | let step = cell + gap; |
| 140 | let left = 30u32; |
| 141 | let top = 18u32; |
| 142 | let width = left + u32::try_from(WEEKS).unwrap_or(53) * step; |
| 143 | let height = top + 7 * step; |
| 144 | |
| 145 | let month_name = |m: time::Month| match m { |
| 146 | time::Month::January => "Jan", |
| 147 | time::Month::February => "Feb", |
| 148 | time::Month::March => "Mar", |
| 149 | time::Month::April => "Apr", |
| 150 | time::Month::May => "May", |
| 151 | time::Month::June => "Jun", |
| 152 | time::Month::July => "Jul", |
| 153 | time::Month::August => "Aug", |
| 154 | time::Month::September => "Sep", |
| 155 | time::Month::October => "Oct", |
| 156 | time::Month::November => "Nov", |
| 157 | time::Month::December => "Dec", |
| 158 | }; |
| 159 | |
| 160 | let mut body = String::new(); |
| 161 | |
| 162 | let mut prev_month: Option<time::Month> = None; |
| 163 | for week in 0..WEEKS { |
| 164 | let wk = u32::try_from(week).unwrap_or(0); |
| 165 | let col_date = self.start + Duration::days(i64::from(wk) * 7); |
| 166 | let m = col_date.month(); |
| 167 | if prev_month != Some(m) { |
| 168 | prev_month = Some(m); |
| 169 | let x = left + wk * step; |
| 170 | let _ = write!( |
| 171 | body, |
| 172 | r#"<text x="{x}" y="12" class="hm-label">{}</text>"#, |
| 173 | month_name(m) |
| 174 | ); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | for (row, label) in [(0u32, "Mon"), (2, "Wed"), (4, "Fri")] { |
| 179 | let y = top + row * step + cell; |
| 180 | let _ = write!( |
| 181 | body, |
| 182 | r#"<text x="0" y="{y}" class="hm-label">{label}</text>"# |
| 183 | ); |
| 184 | } |
| 185 | |
| 186 | for week in 0..WEEKS { |
| 187 | let wk = u32::try_from(week).unwrap_or(0); |
| 188 | for day in 0..7u32 { |
| 189 | let idx = week * 7 + usize::try_from(day).unwrap_or(0); |
| 190 | let count = self.counts[idx]; |
| 191 | let x = left + wk * step; |
| 192 | let y = top + day * step; |
| 193 | let lvl = self.level(count); |
| 194 | let date = self.start + Duration::days(i64::try_from(idx).unwrap_or(0)); |
| 195 | let _ = write!( |
| 196 | body, |
| 197 | r#"<rect x="{x}" y="{y}" width="{cell}" height="{cell}" rx="2" class="hm-cell" data-level="{lvl}"><title>{count} on {}-{:02}-{:02}</title></rect>"#, |
| 198 | date.year(), |
| 199 | u8::from(date.month()), |
| 200 | date.day(), |
| 201 | ); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | html! { |
| 206 | div class="heatmap-wrap" { |
| 207 | |
| 208 | |
| 209 | svg class="heatmap" viewBox=(format!("0 0 {width} {height}")) |
| 210 | preserveAspectRatio="xMidYMid meet" role="img" |
| 211 | aria-label=(format!("{} contributions in the last year", self.total)) { |
| 212 | (PreEscaped(body)) |
| 213 | } |
| 214 | } |
| 215 | p class="muted heatmap-caption" { |
| 216 | (self.total) " contributions in the last year" |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | |
| 222 | |
| 223 | |
| 224 | pub fn feed_section( |
| 225 | &self, |
| 226 | now_ms: i64, |
| 227 | paging: crate::repo::Pagination, |
| 228 | path: &str, |
| 229 | query: Option<&str>, |
| 230 | ) -> Markup { |
| 231 | let start = paging.offset().min(self.feed.len()); |
| 232 | let end = start.saturating_add(paging.per_page).min(self.feed.len()); |
| 233 | let page = &self.feed[start..end]; |
| 234 | let has_next = end < self.feed.len(); |
| 235 | html! { |
| 236 | section class="listing" { |
| 237 | h2 { "Recent activity" } |
| 238 | @if self.feed.is_empty() { |
| 239 | div class="card" { p class="muted" { "No recent activity." } } |
| 240 | } @else { |
| 241 | ul class="activity-feed card" { |
| 242 | @for item in page { |
| 243 | @let base = format!("/{}/{}", item.owner, item.repo); |
| 244 | li class="activity-item" { |
| 245 | div class="activity-head muted" { |
| 246 | (icon(Icon::GitBranch)) |
| 247 | " pushed to " |
| 248 | span class="badge" { (item.branch) } |
| 249 | " at " |
| 250 | a href=(base) { (item.owner) "/" (item.repo) } |
| 251 | span class="activity-time" { (fmt_relative(item.time_ms, now_ms)) } |
| 252 | } |
| 253 | a class="activity-commit" href=(format!("{base}/-/commit/{}", item.oid)) { |
| 254 | span class="mono commit-sha" { (item.short) } |
| 255 | span class="commit-summary" { (item.summary) } |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | @if has_next || paging.page > 1 { |
| 261 | (crate::repo::pagination_nav(path, query, paging, has_next)) |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | |
| 270 | |
| 271 | pub(crate) fn fmt_relative(ms: i64, now_ms: i64) -> String { |
| 272 | let secs = (now_ms - ms).div_euclid(1000).max(0); |
| 273 | let mins = secs / 60; |
| 274 | let hours = mins / 60; |
| 275 | let days = hours / 24; |
| 276 | if secs < 60 { |
| 277 | "just now".to_string() |
| 278 | } else if mins < 60 { |
| 279 | plural(mins, "minute") |
| 280 | } else if hours < 24 { |
| 281 | plural(hours, "hour") |
| 282 | } else if days < 30 { |
| 283 | plural(days, "day") |
| 284 | } else { |
| 285 | match OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)) { |
| 286 | Ok(dt) => format!( |
| 287 | "on {}-{:02}-{:02}", |
| 288 | dt.year(), |
| 289 | u8::from(dt.month()), |
| 290 | dt.day() |
| 291 | ), |
| 292 | Err(_) => "a while ago".to_string(), |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | |
| 298 | fn plural(n: i64, unit: &str) -> String { |
| 299 | if n == 1 { |
| 300 | format!("1 {unit} ago") |
| 301 | } else { |
| 302 | format!("{n} {unit}s ago") |
| 303 | } |
| 304 | } |