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