~gitpocket/gitpocket

1

feat(web): commit activity graph on profiles

commit
18edbd91cc4f2ff5a2aeb52c3f61c653aef60889 browse tree
parent
11f9a49726fae90adda808a9439ca73ddc121323
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-06 21:42 UTC

5 files changed, +98 −2

README.md modified

@@ -15,6 +15,7 @@ Small and lightweight git server for self hosting. Repositories are served over
1515 - 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`
1616 - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip
1717 - 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
1819 - 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
1920 - 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
2021 - 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 (
1515 "slices"
1616 "strconv"
1717 "strings"
18+ "sync"
1819 "time"
1920 )
2021
@@ -428,3 +429,89 @@ func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) {
428429 }
429430 writeFeed(w, feed)
430431 }
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 @@
22 @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')}
33 @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')}
44 @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}
66 *{box-sizing:border-box}
77 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)}
88 body{margin:0}
@@ -329,3 +329,9 @@ pre.rangediff{line-height:1.45}
329329 table.hunk td.l,.hunk .head td{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
330330 table.hunk{table-layout:auto}
331331 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 @@
1616 {{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}}
1717 </aside>
1818 <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}}
1920 {{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}}
2021 </div>
2122 </div>

web.go modified

@@ -759,7 +759,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
759759 bio = s.renderMarkdown([]byte(profile.Bio))
760760 }
761761 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}})
763764 return
764765 }
765766 name := parts[1]