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 {
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 +3 −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); }
internal/web/templates/owner.html +1 −1
@@ -19,7 +19,7 @@
1919<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
2020<div class="actgraph-scroll">
2121<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}}
2323</div>
2424</div>
2525</section>