web: profile lists repositories first; contribution grid gets month labels and a legend !404

merged merged by cmc on 2026-09-18 06:02 UTC · krz/gitbay:stack-225 into main

5 files changed, +97 −14

Layout: unified · split

e2e/profile_test.go +3
@@ -214,6 +214,9 @@ func TestOwnerProfiles(t *testing.T) {
214214 if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) {
215215 t.Error("about renders below the activity graph")
216216 }
217 if strings.Index(body, `<ul class="repolist"`) > strings.Index(body, "<em>small tools</em>") {
218 t.Error("repositories render below the about section")
219 }
217220
218221 // Clearing works the same way as the other fields.
219222 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 {
internal/httpd/activity.go +28 −6
@@ -11,10 +11,18 @@ type activityDay struct {
1111 Pad bool // before the range start / after today
1212}
1313
14type activityWeek []activityDay // 7 days, Sunday first
14// activityWeek is one column of the graph: 7 days, Sunday first, plus the
15// month label to show above it (empty for most weeks).
16type activityWeek struct {
17 Days []activityDay
18 Month string
19}
1520
1621// activityGrid lays a day->count map into 53 week columns ending today,
17// GitHub-style: columns are weeks, rows Sunday..Saturday.
22// GitHub-style: columns are weeks, rows Sunday..Saturday. Each week whose
23// first non-pad day falls within the first 7 days of a month, and whose
24// month differs from the last labelled week, carries that month's
25// three-letter name.
1826func activityGrid(counts map[string]int) ([]activityWeek, int) {
1927 today := time.Now().UTC()
2028 // End the grid on the Saturday of the current week.
@@ -23,20 +31,34 @@ func activityGrid(counts map[string]int) ([]activityWeek, int) {
2331
2432 total := 0
2533 var weeks []activityWeek
34 prevMonth := ""
2635 for d := start; !d.After(end); d = d.AddDate(0, 0, 7) {
27 var week activityWeek
36 var days []activityDay
37 var firstDay time.Time
38 haveFirst := false
2839 for i := 0; i < 7; i++ {
2940 day := d.AddDate(0, 0, i)
3041 key := day.Format("2006-01-02")
3142 if day.After(today) {
32 week = append(week, activityDay{Date: key, Pad: true})
43 days = append(days, activityDay{Date: key, Pad: true})
3344 continue
3445 }
46 if !haveFirst {
47 firstDay = day
48 haveFirst = true
49 }
3550 n := counts[key]
3651 total += n
37 week = append(week, activityDay{Date: key, Count: n, Level: activityLevel(n)})
52 days = append(days, activityDay{Date: key, Count: n, Level: activityLevel(n)})
53 }
54 month := ""
55 if haveFirst && firstDay.Day() <= 7 {
56 if name := firstDay.Month().String()[:3]; name != prevMonth {
57 month = name
58 prevMonth = name
59 }
3860 }
39 weeks = append(weeks, week)
61 weeks = append(weeks, activityWeek{Days: days, Month: month})
4062 }
4163 return weeks, total
4264}
internal/httpd/activity_test.go added +53
@@ -0,0 +1,53 @@
1package httpd
2
3import (
4 "testing"
5 "time"
6)
7
8// TestActivityGridMonthLabels checks the month label activityGrid attaches
9// to each week: at most one label per calendar month, always on the week
10// whose first non-pad day falls in the first 7 days of that month, and
11// never the same month as the immediately preceding label.
12func TestActivityGridMonthLabels(t *testing.T) {
13 weeks, _ := activityGrid(nil)
14
15 labelled := 0
16 prev := ""
17 for _, w := range weeks {
18 if w.Month == "" {
19 continue
20 }
21 labelled++
22 if w.Month == prev {
23 t.Fatalf("consecutive labelled weeks repeat month %q", w.Month)
24 }
25 prev = w.Month
26
27 var first activityDay
28 found := false
29 for _, d := range w.Days {
30 if !d.Pad {
31 first = d
32 found = true
33 break
34 }
35 }
36 if !found {
37 t.Fatalf("week labelled %q has no non-pad day", w.Month)
38 }
39 day, err := time.Parse("2006-01-02", first.Date)
40 if err != nil {
41 t.Fatalf("bad date %q: %v", first.Date, err)
42 }
43 if day.Day() > 7 {
44 t.Fatalf("week labelled %q but first day is day %d of the month", w.Month, day.Day())
45 }
46 if got := day.Month().String()[:3]; got != w.Month {
47 t.Fatalf("label %q does not match month %q of first day", w.Month, got)
48 }
49 }
50 if labelled == 0 {
51 t.Fatal("expected at least one month label across 53 weeks")
52 }
53}
internal/web/static/style.css +6 −2
@@ -1395,8 +1395,9 @@ pre.quickstart { margin: 0 0 var(--sp-4); }
13951395h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; }
13961396/* 53 week columns x 7 day rows, tinted with the link blue */
13971397.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); }
1398.actgraph { display: flex; gap: 3px; width: max-content; }
1399.actweek { display: flex; flex-direction: column; gap: 3px; }
1398.actgraph { display: flex; gap: 3px; width: max-content; padding-top: 18px; }
1399.actweek { display: flex; flex-direction: column; gap: 3px; position: relative; }
1400.actmonth { position: absolute; top: -16px; left: 0; font-size: var(--fs-0); color: var(--muted); }
14001401.actday { width: 11px; height: 11px; border-radius: 2px; background: var(--faint); }
14011402.actday.pad { background: transparent; }
14021403.actday.l0 { background: var(--faint); }
@@ -1404,6 +1405,9 @@ h2 .count { background: none; color: var(--muted); font-weight: 400; font-size:
14041405.actday.l2 { background: color-mix(in srgb, var(--link) 50%, var(--canvas)); }
14051406.actday.l3 { background: color-mix(in srgb, var(--link) 75%, var(--canvas)); }
14061407.actday.l4 { background: var(--link); }
1408.actlegend { display: flex; align-items: center; gap: 3px; margin-top: var(--sp-2); font-size: var(--fs-0); color: var(--muted); }
1409.actlegend span:first-child { margin-right: var(--sp-1); }
1410.actlegend span:last-child { margin-left: var(--sp-1); }
14071411
14081412/* ---- wiki ---- */
14091413.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; }
internal/web/templates/owner.html +7 −6
@@ -9,20 +9,21 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12<h2>repositories <span class="count">{{len .Repos}}</span></h2>
13<ul class="repolist">
14{{range .Repos}}{{template "reporow" .}}
15{{else}}<li class="empty">no visible repositories</li>{{end}}
16</ul>
1217{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
1318<section class="activity">
1419<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
1520<div class="actgraph-scroll">
1621<div class="actgraph">
17{{range .Activity}}<div class="actweek">{{range .}}<span class="actday{{if .Pad}} pad{{else}} l{{.Level}}{{end}}"{{if not .Pad}} title="{{.Count}} on {{.Date}}"{{end}}></span>{{end}}</div>{{end}}
22{{range .Activity}}<div class="actweek">{{if .Month}}<span class="actmonth">{{.Month}}</span>{{end}}{{range .Days}}<span class="actday{{if .Pad}} pad{{else}} l{{.Level}}{{end}}"{{if not .Pad}} title="{{.Count}} on {{.Date}}"{{end}}></span>{{end}}</div>{{end}}
1823</div>
1924</div>
25<p class="actlegend"><span>Less</span><span class="actday l0"></span><span class="actday l1"></span><span class="actday l2"></span><span class="actday l3"></span><span class="actday l4"></span><span>More</span></p>
2026</section>
21<h2>repositories <span class="count">{{len .Repos}}</span></h2>
22<ul class="repolist">
23{{range .Repos}}{{template "reporow" .}}
24{{else}}<li class="empty">no visible repositories</li>{{end}}
25</ul>
2627{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
2728
2829{{if .Self}}