Skip to content

Commit 4acdd67

Browse files
committed
Add "contribution grid" for atoms
Add a GitHub-style contribution grid for atoms. Shows the last 365 days and number of atoms published per day.
1 parent 74a6027 commit 4acdd67

5 files changed

Lines changed: 371 additions & 4 deletions

File tree

build.go

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,112 @@ type Atom struct {
11281128
changed bool `toml:"changed" validate:"-"`
11291129
}
11301130

1131+
// ContributionDay is a single day in the contribution grid.
1132+
type ContributionDay struct {
1133+
Date string
1134+
Count int
1135+
Level int // 0-4, for CSS intensity classes
1136+
}
1137+
1138+
// ContributionWeek is a column in the contribution grid.
1139+
type ContributionWeek struct {
1140+
Days [7]ContributionDay
1141+
}
1142+
1143+
// ContributionGrid holds a year of contribution data for the atom grid.
1144+
type ContributionGrid struct {
1145+
Weeks []ContributionWeek
1146+
MonthLabels []struct {
1147+
Name string
1148+
Offset int // week index where the month starts
1149+
OffsetPct float64
1150+
}
1151+
}
1152+
1153+
// buildContributionGrid builds a GitHub-style contribution grid from atoms
1154+
// covering the last 52 weeks.
1155+
func buildContributionGrid(atoms []*Atom) *ContributionGrid {
1156+
now := time.Now()
1157+
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
1158+
1159+
// Start from the Sunday 52 weeks ago.
1160+
start := today.AddDate(0, 0, -int(today.Weekday())-52*7)
1161+
1162+
// Count atoms per day.
1163+
counts := make(map[string]int)
1164+
for _, a := range atoms {
1165+
day := a.PublishedAt.UTC().Format("2006-01-02")
1166+
counts[day]++
1167+
}
1168+
1169+
// Find max count for scaling levels.
1170+
maxCount := 0
1171+
for _, c := range counts {
1172+
if c > maxCount {
1173+
maxCount = c
1174+
}
1175+
}
1176+
1177+
// Build weeks.
1178+
numDays := int(today.Sub(start).Hours()/24) + 1
1179+
numWeeks := (numDays + 6) / 7
1180+
weeks := make([]ContributionWeek, numWeeks)
1181+
1182+
for i := range numDays {
1183+
d := start.AddDate(0, 0, i)
1184+
week := i / 7
1185+
dow := i % 7
1186+
dateStr := d.Format("2006-01-02")
1187+
count := counts[dateStr]
1188+
1189+
level := 0
1190+
if count > 0 && maxCount > 0 {
1191+
// Scale to 1-4.
1192+
level = (count*3)/maxCount + 1
1193+
if level > 4 {
1194+
level = 4
1195+
}
1196+
}
1197+
1198+
weeks[week].Days[dow] = ContributionDay{
1199+
Date: dateStr,
1200+
Count: count,
1201+
Level: level,
1202+
}
1203+
}
1204+
1205+
// Build month labels.
1206+
type monthLabel struct {
1207+
Name string
1208+
Offset int
1209+
}
1210+
var months []monthLabel
1211+
lastMonth := time.Month(-1)
1212+
for i := range numDays {
1213+
d := start.AddDate(0, 0, i)
1214+
if d.Month() != lastMonth && d.Day() <= 7 {
1215+
months = append(months, monthLabel{
1216+
Name: d.Format("Jan"),
1217+
Offset: i / 7,
1218+
})
1219+
lastMonth = d.Month()
1220+
}
1221+
}
1222+
1223+
grid := &ContributionGrid{
1224+
Weeks: weeks,
1225+
}
1226+
for _, m := range months {
1227+
grid.MonthLabels = append(grid.MonthLabels, struct {
1228+
Name string
1229+
Offset int
1230+
OffsetPct float64
1231+
}{m.Name, m.Offset, float64(m.Offset) / float64(numWeeks) * 100})
1232+
}
1233+
1234+
return grid
1235+
}
1236+
11311237
func (a *Atom) Equal(other *Atom) bool {
11321238
return a.Description == other.Description &&
11331239
slices.EqualFunc(a.Photos, other.Photos, func(a, b *Photo) bool { return a.Equal(b) }) &&
@@ -1981,7 +2087,8 @@ func renderAtomArchive(ctx context.Context, c *modulir.Context, atoms []*Atom, a
19812087
}
19822088

19832089
locals := getLocals(map[string]any{
1984-
"Atoms": atoms,
2090+
"Atoms": atoms,
2091+
"ContributionGrid": buildContributionGrid(atoms),
19852092
})
19862093

19872094
err := dependencies.renderGoTemplate(ctx, c, source, path.Join(c.TargetDir, "atoms/archive"), locals)
@@ -2131,13 +2238,16 @@ func renderAtomIndex(ctx context.Context, c *modulir.Context, atoms []*Atom, ato
21312238
return false, nil
21322239
}
21332240

2241+
grid := buildContributionGrid(atoms)
2242+
21342243
if len(atoms) > maxAtomsIndex {
21352244
atoms = atoms[0:maxAtomsIndex]
21362245
}
21372246

21382247
locals := getLocals(map[string]any{
2139-
"Atoms": atoms,
2140-
"IndexMax": maxAtomsIndex,
2248+
"Atoms": atoms,
2249+
"ContributionGrid": grid,
2250+
"IndexMax": maxAtomsIndex,
21412251
})
21422252

21432253
err := dependencies.renderGoTemplate(ctx, c, source, path.Join(c.TargetDir, "atoms/index.html"), locals)

build_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"slices"
77
"strings"
88
"testing"
9+
"time"
910

1011
"github.com/joeshaw/envdecode"
1112
"github.com/stretchr/testify/require"
@@ -62,6 +63,88 @@ func TestSimplifyMarkdownForSummary(t *testing.T) {
6263
require.Equal(t, "space is trimmed", simplifyMarkdownForSummary(" space is trimmed "))
6364
}
6465

66+
func TestBuildContributionGrid(t *testing.T) {
67+
t.Run("Empty", func(t *testing.T) {
68+
grid := buildContributionGrid(nil)
69+
require.NotNil(t, grid)
70+
require.Greater(t, len(grid.Weeks), 50)
71+
require.NotEmpty(t, grid.MonthLabels)
72+
73+
// All cells should have count 0.
74+
for _, week := range grid.Weeks {
75+
for _, day := range week.Days {
76+
require.Equal(t, 0, day.Count)
77+
require.Equal(t, 0, day.Level)
78+
}
79+
}
80+
})
81+
82+
t.Run("SingleAtom", func(t *testing.T) {
83+
atoms := []*Atom{
84+
{PublishedAt: time.Now().Add(-24 * time.Hour)},
85+
}
86+
grid := buildContributionGrid(atoms)
87+
88+
total := 0
89+
for _, week := range grid.Weeks {
90+
for _, day := range week.Days {
91+
total += day.Count
92+
}
93+
}
94+
require.Equal(t, 1, total)
95+
})
96+
97+
t.Run("MultipleAtomsSameDay", func(t *testing.T) {
98+
// Use yesterday noon UTC to avoid timezone boundary issues.
99+
yesterday := time.Now().AddDate(0, 0, -1)
100+
noon := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 12, 0, 0, 0, time.UTC)
101+
atoms := []*Atom{
102+
{PublishedAt: noon},
103+
{PublishedAt: noon.Add(-1 * time.Hour)},
104+
{PublishedAt: noon.Add(-2 * time.Hour)},
105+
}
106+
grid := buildContributionGrid(atoms)
107+
108+
dateStr := noon.Format("2006-01-02")
109+
var found bool
110+
for _, week := range grid.Weeks {
111+
for _, day := range week.Days {
112+
if day.Date == dateStr {
113+
require.Equal(t, 3, day.Count)
114+
require.Greater(t, day.Level, 0)
115+
found = true
116+
}
117+
}
118+
}
119+
require.True(t, found, "expected cell for %s not found in grid", dateStr)
120+
})
121+
122+
t.Run("OldAtomsExcluded", func(t *testing.T) {
123+
atoms := []*Atom{
124+
{PublishedAt: time.Now().AddDate(-2, 0, 0)},
125+
}
126+
grid := buildContributionGrid(atoms)
127+
128+
total := 0
129+
for _, week := range grid.Weeks {
130+
for _, day := range week.Days {
131+
total += day.Count
132+
}
133+
}
134+
require.Equal(t, 0, total, "atoms older than 52 weeks should not appear")
135+
})
136+
137+
t.Run("MonthLabelsPresent", func(t *testing.T) {
138+
grid := buildContributionGrid(nil)
139+
require.GreaterOrEqual(t, len(grid.MonthLabels), 12)
140+
for _, m := range grid.MonthLabels {
141+
require.NotEmpty(t, m.Name)
142+
require.GreaterOrEqual(t, m.OffsetPct, 0.0)
143+
require.Less(t, m.OffsetPct, 100.0)
144+
}
145+
})
146+
}
147+
65148
func TestTruncateString(t *testing.T) {
66149
require.Equal(t, "Short string unchanged.", truncateString("Short string unchanged.", 100))
67150

0 commit comments

Comments
 (0)