feat(web): commit activity graph on profiles
5 files changed, +98 −2
- README.md modified +1 −0
- extra.go modified +87 −0
- static/style.css modified +7 −1
- templates/user.html modified +1 −0
- web.go modified +2 −1
README.md modified
| @@ -15,6 +15,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 15 | 15 | - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp` |
| 16 | 16 | - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip |
| 17 | 17 | - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title |
| 18 | +- A year of activity on each profile, drawn server side as an SVG with no script. It counts commits on every branch of every public repository whose author email matches the account's verified address, so work on other people's projects counts and nothing is attributed by name alone | |
| 18 | 19 | - Blame, a compare page for any two refs, and tar.gz and zip downloads of any ref. The tar.gz is `git archive --format=tar` passed through a gzip writer with a zeroed header and a fixed level, so the same commit always produces the same bytes and `archive/<ref>.tar.gz.sha256` publishes the checksum that a package recipe can pin |
| 19 | 20 | - Every pushed revision of a pull request is kept under `refs/pull/<n>/v<k>`, and the request page links a `git range-diff` between any two of them, so a reviewer sees what a force push actually changed in each commit instead of rereading the whole series |
| 20 | 21 | - History search on the log page through git's pickaxe, either commits that change how often a string occurs (`-S`) or commits whose diff has a line matching a regex (`-G`), which answers when a line arrived or vanished without cloning |
extra.go modified
| @@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "slices" |
| 16 | 16 | "strconv" |
| 17 | 17 | "strings" |
| 18 | + "sync" | |
| 18 | 19 | "time" |
| 19 | 20 | ) |
| 20 | 21 | |
| @@ -428,3 +429,89 @@ func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) { | ||
| 428 | 429 | } |
| 429 | 430 | writeFeed(w, feed) |
| 430 | 431 | } |
| 432 | + | |
| 433 | +type activityCache struct { | |
| 434 | + sync.Mutex | |
| 435 | + entries map[int64]activityEntry | |
| 436 | +} | |
| 437 | + | |
| 438 | +type activityEntry struct { | |
| 439 | + at time.Time | |
| 440 | + svg template.HTML | |
| 441 | + total int | |
| 442 | +} | |
| 443 | + | |
| 444 | +var activities = activityCache{entries: map[int64]activityEntry{}} | |
| 445 | + | |
| 446 | +func (s *Server) activity(owner User) (template.HTML, int) { | |
| 447 | + activities.Lock() | |
| 448 | + if e, ok := activities.entries[owner.ID]; ok && time.Since(e.at) < 10*time.Minute { | |
| 449 | + activities.Unlock() | |
| 450 | + return e.svg, e.total | |
| 451 | + } | |
| 452 | + activities.Unlock() | |
| 453 | + email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) | |
| 454 | + if email == "" { | |
| 455 | + return "", 0 | |
| 456 | + } | |
| 457 | + today := time.Now().UTC().Truncate(24 * time.Hour) | |
| 458 | + start := today.AddDate(0, 0, -364-int(today.AddDate(0, 0, -364).Weekday())) | |
| 459 | + counts := map[string]int{} | |
| 460 | + seen := map[string]bool{} | |
| 461 | + repos, _ := s.store.PublicRepos("", "") | |
| 462 | + for _, repo := range repos { | |
| 463 | + g := Git{Dir: s.repoDir(repo)} | |
| 464 | + out, err := g.run(nil, "log", "--all", "--no-merges", "--since="+start.Format(time.RFC3339), "--format=%H%x09%at%x09%ae") | |
| 465 | + if err != nil { | |
| 466 | + continue | |
| 467 | + } | |
| 468 | + for _, line := range strings.Split(string(out), "\n") { | |
| 469 | + f := strings.Split(line, "\t") | |
| 470 | + if len(f) != 3 || strings.ToLower(f[2]) != email || seen[f[0]] { | |
| 471 | + continue | |
| 472 | + } | |
| 473 | + seen[f[0]] = true | |
| 474 | + sec, _ := strconv.ParseInt(f[1], 10, 64) | |
| 475 | + counts[time.Unix(sec, 0).UTC().Format("2006-01-02")]++ | |
| 476 | + } | |
| 477 | + } | |
| 478 | + peak, total := 0, 0 | |
| 479 | + for _, n := range counts { | |
| 480 | + peak = max(peak, n) | |
| 481 | + total += n | |
| 482 | + } | |
| 483 | + shades := []string{"var(--heat0)", "var(--heat1)", "var(--heat2)", "var(--heat3)", "var(--heat4)"} | |
| 484 | + const cell, step, left, top = 10, 13, 28, 16 | |
| 485 | + var b strings.Builder | |
| 486 | + weeks := int(today.Sub(start).Hours()/24)/7 + 1 | |
| 487 | + fmt.Fprintf(&b, `<svg class="heat" viewBox="0 0 %d %d" role="img" aria-label="%d commits in the last year">`, left+weeks*step, top+7*step, total) | |
| 488 | + for i, name := range []string{"Mon", "Wed", "Fri"} { | |
| 489 | + fmt.Fprintf(&b, `<text x="0" y="%d">%s</text>`, top+(1+2*i)*step+cell-1, name) | |
| 490 | + } | |
| 491 | + lastMonth := -1 | |
| 492 | + for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { | |
| 493 | + week := int(d.Sub(start).Hours()/24) / 7 | |
| 494 | + if d.Weekday() == 0 && int(d.Month()) != lastMonth && d.Day() <= 7 && week < weeks-1 { | |
| 495 | + lastMonth = int(d.Month()) | |
| 496 | + fmt.Fprintf(&b, `<text x="%d" y="10">%s</text>`, left+week*step, d.Format("Jan")) | |
| 497 | + } | |
| 498 | + n := counts[d.Format("2006-01-02")] | |
| 499 | + level := 0 | |
| 500 | + if n > 0 { | |
| 501 | + level = 1 + min(3, (n-1)*4/max(peak, 1)) | |
| 502 | + } | |
| 503 | + label := "no commits" | |
| 504 | + if n == 1 { | |
| 505 | + label = "1 commit" | |
| 506 | + } else if n > 1 { | |
| 507 | + label = strconv.Itoa(n) + " commits" | |
| 508 | + } | |
| 509 | + fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"><title>%s on %s</title></rect>`, left+week*step, top+int(d.Weekday())*step, cell, cell, shades[level], label, d.Format("2 January 2006")) | |
| 510 | + } | |
| 511 | + b.WriteString(`</svg>`) | |
| 512 | + svg := template.HTML(b.String()) | |
| 513 | + activities.Lock() | |
| 514 | + activities.entries[owner.ID] = activityEntry{time.Now(), svg, total} | |
| 515 | + activities.Unlock() | |
| 516 | + return svg, total | |
| 517 | +} | |
static/style.css modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | @font-face{font-family:"Source Sans 3";font-style:italic;font-weight:400;font-display:swap;src:url(/static/fonts/sans-400-italic.woff2) format('woff2')} |
| 3 | 3 | @font-face{font-family:"Source Sans 3";font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/sans-600-normal.woff2) format('woff2')} |
| 4 | 4 | @font-face{font-family:"Source Sans 3";font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/sans-700-normal.woff2) format('woff2')} |
| 5 | -:root{--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--link:#0640e0;--primary:#0640e0;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa} | |
| 5 | +:root{--heat0:#ebedf0;--heat1:#c3c8cf;--heat2:#868e96;--heat3:#495057;--heat4:#16181b;--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--link:#0640e0;--primary:#0640e0;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa} | |
| 6 | 6 | *{box-sizing:border-box} |
| 7 | 7 | html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","DejaVu Sans",Arial,sans-serif;background:#fff;color:var(--fg)} |
| 8 | 8 | body{margin:0} |
| @@ -329,3 +329,9 @@ pre.rangediff{line-height:1.45} | ||
| 329 | 329 | table.hunk td.l,.hunk .head td{white-space:pre-wrap;overflow-wrap:anywhere;width:100%} |
| 330 | 330 | table.hunk{table-layout:auto} |
| 331 | 331 | pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} |
| 332 | +.activity{margin:0 0 1.2rem;padding:.5rem .6rem .3rem;background:var(--soft)} | |
| 333 | +.activity p{margin:0 0 .3rem} | |
| 334 | +.heat{width:100%;height:auto;display:block} | |
| 335 | +.heat text{font-size:9px;fill:var(--muted);font-family:system-ui,sans-serif} | |
| 336 | +.activity .legend{display:flex;align-items:center;gap:3px;justify-content:flex-end;margin:.3rem 0 0} | |
| 337 | +.activity .legend i{display:inline-block;width:10px;height:10px;border-radius:2px} | |
templates/user.html modified
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | {{if $own}}<a class="btn primary block" href="/create">Create a new repository ▸</a><a class="btn block" href="/settings">Edit your profile ▸</a>{{end}} |
| 17 | 17 | </aside> |
| 18 | 18 | <div class="main"> |
| 19 | +{{with .Data.Activity}}<section class="activity"><p class="muted small">{{$.Data.Total}} commits in the last year across public repositories</p>{{.}}<p class="legend muted small">less <i style="background:var(--heat0)"></i><i style="background:var(--heat1)"></i><i style="background:var(--heat2)"></i><i style="background:var(--heat3)"></i><i style="background:var(--heat4)"></i> more</p></section>{{end}} | |
| 19 | 20 | {{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This user does not have any repositories.</p>{{end}} |
| 20 | 21 | </div> |
| 21 | 22 | </div> |
web.go modified
| @@ -759,7 +759,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 759 | 759 | bio = s.renderMarkdown([]byte(profile.Bio)) |
| 760 | 760 | } |
| 761 | 761 | starred, _ := s.store.StarredRepos(owner.ID) |
| 762 | - s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio, "Count": len(starred)}}) | |
| 762 | + heat, total := s.activity(owner) | |
| 763 | + s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio, "Count": len(starred), "Activity": heat, "Total": total}}) | |
| 763 | 764 | return |
| 764 | 765 | } |
| 765 | 766 | name := parts[1] |