Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 110 additions & 3 deletions build.go
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,109 @@ type Atom struct {
changed bool `toml:"changed" validate:"-"`
}

// ContributionDay is a single day in the contribution grid.
type ContributionDay struct {
Date string
Count int
Level int // 0-4, for CSS intensity classes
}

// ContributionWeek is a column in the contribution grid.
type ContributionWeek struct {
Days [7]ContributionDay
}

// ContributionGrid holds a year of contribution data for the atom grid.
type ContributionGrid struct {
Weeks []ContributionWeek
MonthLabels []struct {
Name string
Offset int // week index where the month starts
OffsetPct float64
}
}

// buildContributionGrid builds a GitHub-style contribution grid from atoms
// covering the last 52 weeks.
func buildContributionGrid(atoms []*Atom) *ContributionGrid {
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)

// Start from the Sunday 52 weeks ago.
start := today.AddDate(0, 0, -int(today.Weekday())-52*7)

// Count atoms per day.
counts := make(map[string]int)
for _, a := range atoms {
day := a.PublishedAt.UTC().Format("2006-01-02")
counts[day]++
}

// Find max count for scaling levels.
maxCount := 0
for _, c := range counts {
if c > maxCount {
maxCount = c
}
}

// Build weeks.
numDays := int(today.Sub(start).Hours()/24) + 1
numWeeks := (numDays + 6) / 7
weeks := make([]ContributionWeek, numWeeks)

for i := range numDays {
d := start.AddDate(0, 0, i)
week := i / 7
dow := i % 7
dateStr := d.Format("2006-01-02")
count := counts[dateStr]

level := 0
if count > 0 && maxCount > 0 {
// Scale to 1-4.
level = min((count*3)/maxCount+1, 4)
}

weeks[week].Days[dow] = ContributionDay{
Date: dateStr,
Count: count,
Level: level,
}
}

// Build month labels.
type monthLabel struct {
Name string
Offset int
}
var months []monthLabel
lastMonth := time.Month(-1)
for i := range numDays {
d := start.AddDate(0, 0, i)
if d.Month() != lastMonth && d.Day() <= 7 {
months = append(months, monthLabel{
Name: d.Format("Jan"),
Offset: i / 7,
})
lastMonth = d.Month()
}
}

grid := &ContributionGrid{
Weeks: weeks,
}
for _, m := range months {
grid.MonthLabels = append(grid.MonthLabels, struct {
Name string
Offset int
OffsetPct float64
}{m.Name, m.Offset, float64(m.Offset) / float64(numWeeks) * 100})
}

return grid
}

func (a *Atom) Equal(other *Atom) bool {
return a.Description == other.Description &&
slices.EqualFunc(a.Photos, other.Photos, func(a, b *Photo) bool { return a.Equal(b) }) &&
Expand Down Expand Up @@ -1981,7 +2084,8 @@ func renderAtomArchive(ctx context.Context, c *modulir.Context, atoms []*Atom, a
}

locals := getLocals(map[string]any{
"Atoms": atoms,
"Atoms": atoms,
"ContributionGrid": buildContributionGrid(atoms),
})

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

grid := buildContributionGrid(atoms)

if len(atoms) > maxAtomsIndex {
atoms = atoms[0:maxAtomsIndex]
}

locals := getLocals(map[string]any{
"Atoms": atoms,
"IndexMax": maxAtomsIndex,
"Atoms": atoms,
"ContributionGrid": grid,
"IndexMax": maxAtomsIndex,
})

err := dependencies.renderGoTemplate(ctx, c, source, path.Join(c.TargetDir, "atoms/index.html"), locals)
Expand Down
90 changes: 90 additions & 0 deletions build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"slices"
"strings"
"testing"
"time"

"github.com/joeshaw/envdecode"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -62,6 +63,95 @@ func TestSimplifyMarkdownForSummary(t *testing.T) {
require.Equal(t, "space is trimmed", simplifyMarkdownForSummary(" space is trimmed "))
}

func TestBuildContributionGrid(t *testing.T) {
t.Parallel()

t.Run("Empty", func(t *testing.T) {
t.Parallel()
grid := buildContributionGrid(nil)
require.NotNil(t, grid)
require.Greater(t, len(grid.Weeks), 50)
require.NotEmpty(t, grid.MonthLabels)

// All cells should have count 0.
for _, week := range grid.Weeks {
for _, day := range week.Days {
require.Equal(t, 0, day.Count)
require.Equal(t, 0, day.Level)
}
}
})

t.Run("SingleAtom", func(t *testing.T) {
t.Parallel()
atoms := []*Atom{
{PublishedAt: time.Now().Add(-24 * time.Hour)},
}
grid := buildContributionGrid(atoms)

total := 0
for _, week := range grid.Weeks {
for _, day := range week.Days {
total += day.Count
}
}
require.Equal(t, 1, total)
})

t.Run("MultipleAtomsSameDay", func(t *testing.T) {
t.Parallel()
// Use yesterday noon UTC to avoid timezone boundary issues.
yesterday := time.Now().AddDate(0, 0, -1)
noon := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 12, 0, 0, 0, time.UTC)
atoms := []*Atom{
{PublishedAt: noon},
{PublishedAt: noon.Add(-1 * time.Hour)},
{PublishedAt: noon.Add(-2 * time.Hour)},
}
grid := buildContributionGrid(atoms)

dateStr := noon.Format("2006-01-02")
var found bool
for _, week := range grid.Weeks {
for _, day := range week.Days {
if day.Date == dateStr {
require.Equal(t, 3, day.Count)
require.Positive(t, day.Level)
found = true
}
}
}
require.True(t, found, "expected cell for %s not found in grid", dateStr)
})

t.Run("OldAtomsExcluded", func(t *testing.T) {
t.Parallel()
atoms := []*Atom{
{PublishedAt: time.Now().AddDate(-2, 0, 0)},
}
grid := buildContributionGrid(atoms)

total := 0
for _, week := range grid.Weeks {
for _, day := range week.Days {
total += day.Count
}
}
require.Equal(t, 0, total, "atoms older than 52 weeks should not appear")
})

t.Run("MonthLabelsPresent", func(t *testing.T) {
t.Parallel()
grid := buildContributionGrid(nil)
require.GreaterOrEqual(t, len(grid.MonthLabels), 12)
for _, m := range grid.MonthLabels {
require.NotEmpty(t, m.Name)
require.GreaterOrEqual(t, m.OffsetPct, 0.0)
require.Less(t, m.OffsetPct, 100.0)
}
})
}

func TestTruncateString(t *testing.T) {
require.Equal(t, "Short string unchanged.", truncateString("Short string unchanged.", 100))

Expand Down
Loading