web: profile lists repositories first; contribution grid gets month labels and a legend !404
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 | ||
| 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 | // 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. | ||
| 18 | func activityGrid(counts map[string]int) ([]activityWeek, int) { | 26 | func 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 @@ | |||
| 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 | h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; } | 1395 | h2 .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}} |