feat(web): day tooltips and year tabs on the graph
5 files changed, +97 −40
- README.md modified +1 −1
- extra.go modified +74 −34
- static/style.css modified +16 −2
- templates/user.html modified +1 −1
- web.go modified +5 −2
README.md modified
| @@ -15,7 +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 | +- 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 | |
| 19 | 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 |
| 20 | 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 |
| 21 | 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
| @@ -436,32 +436,29 @@ type activityCache struct { | ||
| 436 | 436 | } |
| 437 | 437 | |
| 438 | 438 | type activityEntry struct { |
| 439 | - at time.Time | |
| 440 | - svg template.HTML | |
| 441 | - total int | |
| 439 | + at time.Time | |
| 440 | + counts map[string]int | |
| 442 | 441 | } |
| 443 | 442 | |
| 444 | 443 | var activities = activityCache{entries: map[int64]activityEntry{}} |
| 445 | 444 | |
| 446 | -func (s *Server) activity(owner User) (template.HTML, int) { | |
| 445 | +func (s *Server) activityCounts(owner User) map[string]int { | |
| 447 | 446 | activities.Lock() |
| 448 | 447 | if e, ok := activities.entries[owner.ID]; ok && time.Since(e.at) < 10*time.Minute { |
| 449 | 448 | activities.Unlock() |
| 450 | - return e.svg, e.total | |
| 449 | + return e.counts | |
| 451 | 450 | } |
| 452 | 451 | activities.Unlock() |
| 452 | + counts := map[string]int{} | |
| 453 | 453 | email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) |
| 454 | 454 | if email == "" { |
| 455 | - return "", 0 | |
| 455 | + return counts | |
| 456 | 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 | 457 | seen := map[string]bool{} |
| 461 | 458 | repos, _ := s.store.PublicRepos("", "") |
| 462 | 459 | for _, repo := range repos { |
| 463 | 460 | 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") | |
| 465 | 462 | if err != nil { |
| 466 | 463 | continue |
| 467 | 464 | } |
| @@ -475,43 +472,86 @@ func (s *Server) activity(owner User) (template.HTML, int) { | ||
| 475 | 472 | counts[time.Unix(sec, 0).UTC().Format("2006-01-02")]++ |
| 476 | 473 | } |
| 477 | 474 | } |
| 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")] | |
| 480 | 511 | peak = max(peak, n) |
| 481 | - total += n | |
| 512 | + view.Total += n | |
| 482 | 513 | } |
| 483 | - shades := []string{"var(--heat0)", "var(--heat1)", "var(--heat2)", "var(--heat3)", "var(--heat4)"} | |
| 484 | - const cell, step, left, top = 10, 13, 28, 16 | |
| 485 | 514 | 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) | |
| 491 | 516 | 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 { | |
| 495 | 523 | 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 | |
| 497 | 532 | } |
| 498 | 533 | n := counts[d.Format("2006-01-02")] |
| 499 | 534 | level := 0 |
| 500 | 535 | if n > 0 { |
| 501 | 536 | level = 1 + min(3, (n-1)*4/max(peak, 1)) |
| 502 | 537 | } |
| 503 | - label := "no commits" | |
| 538 | + tip := "No commits" | |
| 504 | 539 | if n == 1 { |
| 505 | - label = "1 commit" | |
| 540 | + tip = "1 commit" | |
| 506 | 541 | } 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" | |
| 508 | 551 | } |
| 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) | |
| 510 | 553 | } |
| 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 | |
| 517 | 557 | } |
static/style.css modified
| @@ -331,7 +331,21 @@ table.hunk{table-layout:auto} | ||
| 331 | 331 | pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} |
| 332 | 332 | .activity{margin:0 0 1.2rem;padding:.5rem .6rem .3rem;background:var(--soft)} |
| 333 | 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} | |
| 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)} | |
| 336 | 350 | .activity .legend{display:flex;align-items:center;gap:3px;justify-content:flex-end;margin:.3rem 0 0} |
| 337 | 351 | .activity .legend i{display:inline-block;width:10px;height:10px;border-radius:2px} |
templates/user.html modified
| @@ -16,7 +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 | +{{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}} | |
| 20 | 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}} |
| 21 | 21 | </div> |
| 22 | 22 | </div> |
web.go modified
| @@ -759,8 +759,11 @@ 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 | - 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)}}) | |
| 764 | 767 | return |
| 765 | 768 | } |
| 766 | 769 | name := parts[1] |