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) { |
| 214 | 214 | if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) { |
| 215 | 215 | t.Error("about renders below the activity graph") |
| 216 | 216 | } |
| 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 | } |
| 217 | 220 | |
| 218 | 221 | // Clearing works the same way as the other fields. |
| 219 | 222 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 { |
internal/httpd/activity.go
+28 −6
| @@ -11,10 +11,18 @@ type activityDay struct { |
| 11 | 11 | Pad bool // before the range start / after today |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | | type 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). |
| 16 | type activityWeek struct { |
| 17 | Days []activityDay |
| 18 | Month string |
| 19 | } |
| 15 | 20 | |
| 16 | 21 | // 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. |
| 18 | 26 | func activityGrid(counts map[string]int) ([]activityWeek, int) { |
| 19 | 27 | today := time.Now().UTC() |
| 20 | 28 | // End the grid on the Saturday of the current week. |
| @@ -23,20 +31,34 @@ func activityGrid(counts map[string]int) ([]activityWeek, int) { |
| 23 | 31 | |
| 24 | 32 | total := 0 |
| 25 | 33 | var weeks []activityWeek |
| 34 | prevMonth := "" |
| 26 | 35 | 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 |
| 28 | 39 | for i := 0; i < 7; i++ { |
| 29 | 40 | day := d.AddDate(0, 0, i) |
| 30 | 41 | key := day.Format("2006-01-02") |
| 31 | 42 | if day.After(today) { |
| 32 | | week = append(week, activityDay{Date: key, Pad: true}) |
| 43 | days = append(days, activityDay{Date: key, Pad: true}) |
| 33 | 44 | continue |
| 34 | 45 | } |
| 46 | if !haveFirst { |
| 47 | firstDay = day |
| 48 | haveFirst = true |
| 49 | } |
| 35 | 50 | n := counts[key] |
| 36 | 51 | 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 | } |
| 38 | 60 | } |
| 39 | | weeks = append(weeks, week) |
| 61 | weeks = append(weeks, activityWeek{Days: days, Month: month}) |
| 40 | 62 | } |
| 41 | 63 | return weeks, total |
| 42 | 64 | } |
internal/httpd/activity_test.go
added
+53
| @@ -0,0 +1,53 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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. |
| 12 | func 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); } |
| 1395 | 1395 | h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; } |
| 1396 | 1396 | /* 53 week columns x 7 day rows, tinted with the link blue */ |
| 1397 | 1397 | .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); } |
| 1400 | 1401 | .actday { width: 11px; height: 11px; border-radius: 2px; background: var(--faint); } |
| 1401 | 1402 | .actday.pad { background: transparent; } |
| 1402 | 1403 | .actday.l0 { background: var(--faint); } |
| @@ -1404,6 +1405,9 @@ h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: |
| 1404 | 1405 | .actday.l2 { background: color-mix(in srgb, var(--link) 50%, var(--canvas)); } |
| 1405 | 1406 | .actday.l3 { background: color-mix(in srgb, var(--link) 75%, var(--canvas)); } |
| 1406 | 1407 | .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); } |
| 1407 | 1411 | |
| 1408 | 1412 | /* ---- wiki ---- */ |
| 1409 | 1413 | .wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } |
internal/web/templates/owner.html
+7 −6
| @@ -9,20 +9,21 @@ |
| 9 | 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} |
| 10 | 10 | {{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} |
| 11 | 11 | </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> |
| 12 | 17 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} |
| 13 | 18 | <section class="activity"> |
| 14 | 19 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 15 | 20 | <div class="actgraph-scroll"> |
| 16 | 21 | <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}} |
| 18 | 23 | </div> |
| 19 | 24 | </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> |
| 20 | 26 | </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> |
| 26 | 27 | {{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}} |
| 27 | 28 | |
| 28 | 29 | {{if .Self}} |