~gitpocket/gitpocket

1

feat(web): day tooltips and year tabs on the graph

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

5 files changed, +97 −40

README.md modified

@@ -15,7 +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
18+- A year of activity on each profile, drawn server side as a grid with no script, with a tab for the trailing year and one for each calendar year that has commits. Hovering or focusing a day shows its count in a CSS tooltip. 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
1919 - 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
2020 - 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
2121 - 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

@@ -436,32 +436,29 @@ type activityCache struct {
436436 }
437437
438438 type activityEntry struct {
439- at time.Time
440- svg template.HTML
441- total int
439+ at time.Time
440+ counts map[string]int
442441 }
443442
444443 var activities = activityCache{entries: map[int64]activityEntry{}}
445444
446-func (s *Server) activity(owner User) (template.HTML, int) {
445+func (s *Server) activityCounts(owner User) map[string]int {
447446 activities.Lock()
448447 if e, ok := activities.entries[owner.ID]; ok && time.Since(e.at) < 10*time.Minute {
449448 activities.Unlock()
450- return e.svg, e.total
449+ return e.counts
451450 }
452451 activities.Unlock()
452+ counts := map[string]int{}
453453 email := strings.ToLower(s.store.VerifiedEmail(owner.ID))
454454 if email == "" {
455- return "", 0
455+ return counts
456456 }
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{}
460457 seen := map[string]bool{}
461458 repos, _ := s.store.PublicRepos("", "")
462459 for _, repo := range repos {
463460 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")
461+ out, err := g.run(nil, "log", "--all", "--no-merges", "--format=%H%x09%at%x09%ae")
465462 if err != nil {
466463 continue
467464 }
@@ -475,43 +472,86 @@ func (s *Server) activity(owner User) (template.HTML, int) {
475472 counts[time.Unix(sec, 0).UTC().Format("2006-01-02")]++
476473 }
477474 }
478- peak, total := 0, 0
479- for _, n := range counts {
475+ activities.Lock()
476+ activities.entries[owner.ID] = activityEntry{time.Now(), counts}
477+ activities.Unlock()
478+ return counts
479+}
480+
481+type heatView struct {
482+ Grid template.HTML
483+ Total int
484+ Year int
485+ Years []int
486+}
487+
488+func (s *Server) activity(owner User, year int) heatView {
489+ counts := s.activityCounts(owner)
490+ today := time.Now().UTC().Truncate(24 * time.Hour)
491+ years := map[int]bool{today.Year(): true}
492+ for day := range counts {
493+ y, _ := strconv.Atoi(day[:4])
494+ years[y] = true
495+ }
496+ view := heatView{Year: year}
497+ for y := range years {
498+ view.Years = append(view.Years, y)
499+ }
500+ slices.SortFunc(view.Years, func(a, b int) int { return b - a })
501+ first, last := today.AddDate(0, 0, -364), today
502+ if year != 0 {
503+ first = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
504+ last = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
505+ }
506+ start := first.AddDate(0, 0, -int(first.Weekday()))
507+ weeks := int(last.Sub(start).Hours()/24)/7 + 1
508+ peak := 0
509+ for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
510+ n := counts[d.Format("2006-01-02")]
480511 peak = max(peak, n)
481- total += n
512+ view.Total += n
482513 }
483- shades := []string{"var(--heat0)", "var(--heat1)", "var(--heat2)", "var(--heat3)", "var(--heat4)"}
484- const cell, step, left, top = 10, 13, 28, 16
485514 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- }
515+ fmt.Fprintf(&b, `<div class="heat" style="--weeks:%d"><div class="months">`, weeks)
491516 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 {
517+ for w := 0; w < weeks; w++ {
518+ d := start.AddDate(0, 0, 7*w)
519+ if d.Before(first) {
520+ d = first
521+ }
522+ if int(d.Month()) != lastMonth && w < weeks-2 {
495523 lastMonth = int(d.Month())
496- fmt.Fprintf(&b, `<text x="%d" y="10">%s</text>`, left+week*step, d.Format("Jan"))
524+ fmt.Fprintf(&b, `<span style="grid-column:%d">%s</span>`, w+1, d.Format("Jan"))
525+ }
526+ }
527+ b.WriteString(`</div><div class="days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span></div><div class="cells">`)
528+ for d := start; !d.After(last) || d.Weekday() != 0; d = d.AddDate(0, 0, 1) {
529+ if d.Before(first) || d.After(last) {
530+ b.WriteString(`<span class="pad"></span>`)
531+ continue
497532 }
498533 n := counts[d.Format("2006-01-02")]
499534 level := 0
500535 if n > 0 {
501536 level = 1 + min(3, (n-1)*4/max(peak, 1))
502537 }
503- label := "no commits"
538+ tip := "No commits"
504539 if n == 1 {
505- label = "1 commit"
540+ tip = "1 commit"
506541 } else if n > 1 {
507- label = strconv.Itoa(n) + " commits"
542+ tip = strconv.Itoa(n) + " commits"
543+ }
544+ tip += " on " + d.Format("2 January 2006")
545+ week := int(d.Sub(start).Hours()/24) / 7
546+ edge := ""
547+ if week < 6 {
548+ edge = " l"
549+ } else if week > weeks-7 {
550+ edge = " r"
508551 }
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"))
552+ fmt.Fprintf(&b, `<span class="c h%d%s" data-tip="%s" aria-label="%s" tabindex="0"></span>`, level, edge, tip, tip)
510553 }
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
554+ b.WriteString(`</div></div>`)
555+ view.Grid = template.HTML(b.String())
556+ return view
517557 }

static/style.css modified

@@ -331,7 +331,21 @@ table.hunk{table-layout:auto}
331331 pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
332332 .activity{margin:0 0 1.2rem;padding:.5rem .6rem .3rem;background:var(--soft)}
333333 .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}
334+.heathead{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;flex-wrap:wrap;margin-bottom:.3rem}
335+.years{display:flex;gap:.2rem;flex-wrap:wrap;font-size:.8rem}
336+.years a{color:var(--muted);padding:0 .35rem}
337+.years a.active{background:var(--fg);color:#fff}
338+.heat{display:grid;grid-template-columns:2rem 1fr;grid-template-rows:auto auto;font-size:9px;color:var(--muted);font-family:system-ui,sans-serif}
339+.heat .months{grid-column:2;grid-row:1;display:grid;grid-template-columns:repeat(var(--weeks),1fr);height:1.1rem}
340+.heat .months span{white-space:nowrap}
341+.heat .days{grid-column:1;grid-row:2;display:grid;grid-template-rows:repeat(7,1fr);gap:2px}
342+.heat .days span{line-height:1;display:flex;align-items:center}
343+.heat .cells{grid-column:2;grid-row:2;display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,auto);grid-template-columns:repeat(var(--weeks),1fr);gap:2px}
344+.heat .c,.heat .pad{aspect-ratio:1;border-radius:2px;position:relative}
345+.heat .c:hover,.heat .c:focus{outline:1px solid var(--fg);outline-offset:0}
346+.heat .c:hover::after,.heat .c:focus::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#16181b;color:#fff;font-size:12px;padding:.2rem .45rem;border-radius:3px;white-space:nowrap;z-index:5;pointer-events:none}
347+.heat .c.l:hover::after,.heat .c.l:focus::after{left:0;transform:none}
348+.heat .c.r:hover::after,.heat .c.r:focus::after{left:auto;right:0;transform:none}
349+.h0{background:var(--heat0)}.h1{background:var(--heat1)}.h2{background:var(--heat2)}.h3{background:var(--heat3)}.h4{background:var(--heat4)}
336350 .activity .legend{display:flex;align-items:center;gap:3px;justify-content:flex-end;margin:.3rem 0 0}
337351 .activity .legend i{display:inline-block;width:10px;height:10px;border-radius:2px}

templates/user.html modified

@@ -16,7 +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}}
19+{{with .Data.Activity}}<section class="activity"><div class="heathead"><p class="muted small">{{.Total}} commit{{if ne .Total 1}}s{{end}} {{if .Year}}in {{.Year}}{{else}}in the last year{{end}}</p><nav class="years"><a href="?"{{if not .Year}} class="active"{{end}}>last year</a>{{range .Years}}<a href="?year={{.}}"{{if eq . $.Data.Activity.Year}} class="active"{{end}}>{{.}}</a>{{end}}</nav></div>{{.Grid}}<p class="legend muted small">less <i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i> more</p></section>{{end}}
2020 {{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}}
2121 </div>
2222 </div>

web.go modified

@@ -759,8 +759,11 @@ 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- 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}})
762+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
763+ if year < 1970 || year > time.Now().Year() {
764+ year = 0
765+ }
766+ 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": s.activity(owner, year)}})
764767 return
765768 }
766769 name := parts[1]