Commit 6f188b5803
6f188b5803e8f1b5abff0f4a1e82bfc2895679a6
parent: f8b1aa4b9d
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-18 04:29 UTC
httpd, web: month labels on the contribution grid
activityWeek carries a Month label alongside its days: the week whose
first non-pad day falls in the first 7 days of a month gets that
month's three-letter name, skipped when it repeats the last label.
Ref #225
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
+3 −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); } |
internal/web/templates/owner.html
+1 −1
| @@ -19,7 +19,7 @@ |
| 19 | 19 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 20 | 20 | <div class="actgraph-scroll"> |
| 21 | 21 | <div class="actgraph"> |
| 22 | | {{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}} |
| 23 | 23 | </div> |
| 24 | 24 | </div> |
| 25 | 25 | </section> |