tenseleyflow/shithub / 8b3c058

Browse files

Count profile contributions across repos

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8b3c0588d3f13ad2e007aa50ab56609fe0fc600c
Parents
a19cb1d
Tree
e98b98f

8 changed files

StatusFile+-
M internal/repos/queries/repos.sql 14 1
M internal/repos/sqlc/querier.go 1 0
M internal/repos/sqlc/repos.sql.go 70 0
M internal/web/handlers/profile/overview.go 169 24
M internal/web/handlers/profile/profile.go 1 1
M internal/web/handlers/profile/profile_test.go 117 5
M internal/web/static/css/shithub.css 55 2
M internal/web/templates/profile/view.html 11 5
internal/repos/queries/repos.sqlmodified
@@ -100,6 +100,20 @@ FROM repos
100100
 WHERE owner_org_id = $1 AND deleted_at IS NULL
101101
 ORDER BY updated_at DESC;
102102
 
103
+-- name: ListPublicContributionRepos :many
104
+SELECT sqlc.embed(r), COALESCE(u.username, o.slug)::text AS owner_slug
105
+FROM repos r
106
+LEFT JOIN users u ON u.id = r.owner_user_id
107
+LEFT JOIN orgs o ON o.id = r.owner_org_id
108
+WHERE r.deleted_at IS NULL
109
+  AND r.visibility = 'public'
110
+  AND (
111
+    (r.owner_user_id IS NOT NULL AND u.deleted_at IS NULL AND u.suspended_at IS NULL)
112
+    OR (r.owner_org_id IS NOT NULL AND o.deleted_at IS NULL)
113
+  )
114
+ORDER BY r.updated_at DESC, r.id DESC
115
+LIMIT $1;
116
+
103117
 -- name: UpdateRepoGeneralSettings :exec
104118
 -- S32: General-tab settings persist via this single query so each
105119
 -- form post is one round-trip. The merge-method toggles are kept
@@ -278,4 +292,3 @@ WHERE r.fork_of_repo_id = $1
278292
 -- without waiting. Replaces the inline UPDATE in admin/repos.go
279293
 -- (SR2 M2).
280294
 UPDATE repos SET deleted_at = now() - interval '1 year' WHERE id = $1;
281
-
internal/repos/sqlc/querier.gomodified
@@ -93,6 +93,7 @@ type Querier interface {
9393
 	// Inbox view: pending offers a user can act on.
9494
 	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
9595
 	ListProfilePinsForSet(ctx context.Context, db DBTX, setID int64) ([]ListProfilePinsForSetRow, error)
96
+	ListPublicContributionRepos(ctx context.Context, db DBTX, limit int32) ([]ListPublicContributionReposRow, error)
9697
 	// ─── soft-delete sweep query ───────────────────────────────────────────
9798
 	// The repo:hard_delete enqueuer queries this to find rows ready for
9899
 	// destruction. The 7-day grace is hard-coded here; if we add a config
internal/repos/sqlc/repos.sql.gomodified
@@ -653,6 +653,76 @@ func (q *Queries) ListProfilePinsForSet(ctx context.Context, db DBTX, setID int6
653653
 	return items, nil
654654
 }
655655
 
656
+const listPublicContributionRepos = `-- name: ListPublicContributionRepos :many
657
+SELECT r.id, r.owner_user_id, r.owner_org_id, r.name, r.description, r.visibility, r.default_branch, r.is_archived, r.archived_at, r.deleted_at, r.disk_used_bytes, r.fork_of_repo_id, r.license_key, r.primary_language, r.has_issues, r.has_pulls, r.created_at, r.updated_at, r.default_branch_oid, r.allow_squash_merge, r.allow_rebase_merge, r.allow_merge_commit, r.default_merge_method, r.star_count, r.watcher_count, r.fork_count, r.init_status, r.last_indexed_oid, COALESCE(u.username, o.slug)::text AS owner_slug
658
+FROM repos r
659
+LEFT JOIN users u ON u.id = r.owner_user_id
660
+LEFT JOIN orgs o ON o.id = r.owner_org_id
661
+WHERE r.deleted_at IS NULL
662
+  AND r.visibility = 'public'
663
+  AND (
664
+    (r.owner_user_id IS NOT NULL AND u.deleted_at IS NULL AND u.suspended_at IS NULL)
665
+    OR (r.owner_org_id IS NOT NULL AND o.deleted_at IS NULL)
666
+  )
667
+ORDER BY r.updated_at DESC, r.id DESC
668
+LIMIT $1
669
+`
670
+
671
+type ListPublicContributionReposRow struct {
672
+	Repo      Repo
673
+	OwnerSlug string
674
+}
675
+
676
+func (q *Queries) ListPublicContributionRepos(ctx context.Context, db DBTX, limit int32) ([]ListPublicContributionReposRow, error) {
677
+	rows, err := db.Query(ctx, listPublicContributionRepos, limit)
678
+	if err != nil {
679
+		return nil, err
680
+	}
681
+	defer rows.Close()
682
+	items := []ListPublicContributionReposRow{}
683
+	for rows.Next() {
684
+		var i ListPublicContributionReposRow
685
+		if err := rows.Scan(
686
+			&i.Repo.ID,
687
+			&i.Repo.OwnerUserID,
688
+			&i.Repo.OwnerOrgID,
689
+			&i.Repo.Name,
690
+			&i.Repo.Description,
691
+			&i.Repo.Visibility,
692
+			&i.Repo.DefaultBranch,
693
+			&i.Repo.IsArchived,
694
+			&i.Repo.ArchivedAt,
695
+			&i.Repo.DeletedAt,
696
+			&i.Repo.DiskUsedBytes,
697
+			&i.Repo.ForkOfRepoID,
698
+			&i.Repo.LicenseKey,
699
+			&i.Repo.PrimaryLanguage,
700
+			&i.Repo.HasIssues,
701
+			&i.Repo.HasPulls,
702
+			&i.Repo.CreatedAt,
703
+			&i.Repo.UpdatedAt,
704
+			&i.Repo.DefaultBranchOid,
705
+			&i.Repo.AllowSquashMerge,
706
+			&i.Repo.AllowRebaseMerge,
707
+			&i.Repo.AllowMergeCommit,
708
+			&i.Repo.DefaultMergeMethod,
709
+			&i.Repo.StarCount,
710
+			&i.Repo.WatcherCount,
711
+			&i.Repo.ForkCount,
712
+			&i.Repo.InitStatus,
713
+			&i.Repo.LastIndexedOid,
714
+			&i.OwnerSlug,
715
+		); err != nil {
716
+			return nil, err
717
+		}
718
+		items = append(items, i)
719
+	}
720
+	if err := rows.Err(); err != nil {
721
+		return nil, err
722
+	}
723
+	return items, nil
724
+}
725
+
656726
 const listRepoTopics = `-- name: ListRepoTopics :many
657727
 
658728
 SELECT topic FROM repo_topics WHERE repo_id = $1 ORDER BY topic ASC
internal/web/handlers/profile/overview.gomodified
@@ -29,8 +29,8 @@ import (
2929
 
3030
 const (
3131
 	profileContribWeeks      = 53
32
-	profileContribRepoLimit  = 80
33
-	profileContribMaxPerRepo = 2000
32
+	profileContribRepoLimit  = 500
33
+	profileContribMaxPerRepo = 5000
3434
 	profileReadmeMaxBytes    = 1 * 1024 * 1024
3535
 )
3636
 
@@ -50,15 +50,23 @@ type profileReadme struct {
5050
 
5151
 type contributionCalendar struct {
5252
 	Total             int
53
+	Period            string
5354
 	Weeks             []contributionWeek
54
-	Years             []int
55
+	Years             []contributionYear
5556
 	CurrentYear       int
57
+	SelectedYear      int
5658
 	MonthLabel        string
5759
 	MonthCommitCount  int
5860
 	MonthRepoCount    int
5961
 	HasRepositoryData bool
6062
 }
6163
 
64
+type contributionYear struct {
65
+	Year   int
66
+	Href   string
67
+	Active bool
68
+}
69
+
6270
 type contributionWeek struct {
6371
 	MonthLabel string
6472
 	Days       []contributionDay
@@ -73,6 +81,11 @@ type contributionDay struct {
7381
 	IsInWindow bool
7482
 }
7583
 
84
+type profileContributionRepo struct {
85
+	Repo      reposdb.Repo
86
+	OwnerSlug string
87
+}
88
+
7689
 func (h *Handlers) visibleUserRepos(ctx context.Context, userID int64, viewer middleware.CurrentUser) []reposdb.Repo {
7790
 	rows, err := reposdb.New().ListReposForOwnerUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
7891
 	if err != nil {
@@ -183,27 +196,38 @@ func (h *Handlers) profileReadme(ctx context.Context, user usersdb.User, viewer
183196
 	return profileReadme{}, false
184197
 }
185198
 
186
-func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, repos []reposdb.Repo) contributionCalendar {
199
+func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, viewer middleware.CurrentUser, query url.Values) contributionCalendar {
187200
 	now := time.Now().UTC()
188201
 	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
202
+	currentYear := today.Year()
203
+	selectedYear := selectedContributionYear(query, currentYear)
189204
 	windowStart := today.AddDate(-1, 0, 1)
205
+	windowEndDay := today
206
+	period := "in the last year"
207
+	if selectedYear != currentYear {
208
+		windowStart = time.Date(selectedYear, time.January, 1, 0, 0, 0, 0, time.UTC)
209
+		windowEndDay = time.Date(selectedYear, time.December, 31, 0, 0, 0, 0, time.UTC)
210
+		period = "in " + strconv.Itoa(selectedYear)
211
+	}
190212
 	gridStart := windowStart.AddDate(0, 0, -int(windowStart.Weekday()))
191
-	windowEnd := today.Add(24 * time.Hour)
213
+	windowEnd := windowEndDay.Add(24 * time.Hour)
214
+	activityMonth := time.Date(windowEndDay.Year(), windowEndDay.Month(), 1, 0, 0, 0, 0, time.UTC)
215
+	repos := h.profileContributionRepos(ctx, user, viewer)
192216
 
193217
 	counts := map[string]int{}
194218
 	reposWithMonthActivity := map[int64]struct{}{}
195219
 	if h.d.RepoFS != nil && len(repos) > 0 {
196220
 		emails := h.verifiedEmails(ctx, user.ID)
197
-		for i, repo := range repos {
221
+		for i, source := range repos {
198222
 			if i >= profileContribRepoLimit {
199223
 				break
200224
 			}
201
-			gitDir, err := h.d.RepoFS.RepoPath(user.Username, repo.Name)
225
+			gitDir, err := h.d.RepoFS.RepoPath(source.OwnerSlug, source.Repo.Name)
202226
 			if err != nil {
203227
 				continue
204228
 			}
205229
 			commits, err := repogit.Log(ctx, gitDir, repogit.LogOptions{
206
-				Ref:      repo.DefaultBranch,
230
+				Ref:      source.Repo.DefaultBranch,
207231
 				MaxCount: profileContribMaxPerRepo,
208232
 				Since:    windowStart,
209233
 				Until:    windowEnd,
@@ -212,19 +236,17 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
212236
 				continue
213237
 			}
214238
 			for _, commit := range commits {
215
-				if len(emails) > 0 {
216
-					if _, ok := emails[strings.ToLower(strings.TrimSpace(commit.AuthorEmail))]; !ok {
217
-						continue
218
-					}
239
+				if !commitMatchesProfileUser(commit, emails, user) {
240
+					continue
219241
 				}
220242
 				day := time.Date(commit.AuthorWhen.UTC().Year(), commit.AuthorWhen.UTC().Month(), commit.AuthorWhen.UTC().Day(), 0, 0, 0, 0, time.UTC)
221
-				if day.Before(windowStart) || day.After(today) {
243
+				if day.Before(windowStart) || day.After(windowEndDay) {
222244
 					continue
223245
 				}
224246
 				key := day.Format("2006-01-02")
225247
 				counts[key]++
226
-				if day.Year() == today.Year() && day.Month() == today.Month() {
227
-					reposWithMonthActivity[repo.ID] = struct{}{}
248
+				if day.Year() == activityMonth.Year() && day.Month() == activityMonth.Month() {
249
+					reposWithMonthActivity[source.Repo.ID] = struct{}{}
228250
 				}
229251
 			}
230252
 		}
@@ -239,10 +261,10 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
239261
 			day := gridStart.AddDate(0, 0, w*7+d)
240262
 			key := day.Format("2006-01-02")
241263
 			count := counts[key]
242
-			inWindow := !day.Before(windowStart) && !day.After(today)
264
+			inWindow := !day.Before(windowStart) && !day.After(windowEndDay)
243265
 			if inWindow {
244266
 				total += count
245
-				if day.Year() == today.Year() && day.Month() == today.Month() {
267
+				if day.Year() == activityMonth.Year() && day.Month() == activityMonth.Month() {
246268
 					monthCommitCount += count
247269
 				}
248270
 			}
@@ -260,19 +282,126 @@ func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User,
260282
 		}
261283
 		weeks = append(weeks, week)
262284
 	}
263
-	years := []int{today.Year(), today.Year() - 1, today.Year() - 2, today.Year() - 3}
264285
 	return contributionCalendar{
265286
 		Total:             total,
287
+		Period:            period,
266288
 		Weeks:             weeks,
267
-		Years:             years,
268
-		CurrentYear:       today.Year(),
269
-		MonthLabel:        today.Format("January 2006"),
289
+		Years:             contributionYears(user.Username, currentYear, selectedYear),
290
+		CurrentYear:       currentYear,
291
+		SelectedYear:      selectedYear,
292
+		MonthLabel:        activityMonth.Format("January 2006"),
270293
 		MonthCommitCount:  monthCommitCount,
271294
 		MonthRepoCount:    len(reposWithMonthActivity),
272295
 		HasRepositoryData: h.d.RepoFS != nil && len(repos) > 0,
273296
 	}
274297
 }
275298
 
299
+func (h *Handlers) profileContributionRepos(ctx context.Context, user usersdb.User, viewer middleware.CurrentUser) []profileContributionRepo {
300
+	actor := policy.AnonymousActor()
301
+	if !viewer.IsAnonymous() {
302
+		actor = viewer.PolicyActor()
303
+	}
304
+	deps := policy.Deps{Pool: h.d.Pool}
305
+	queries := reposdb.New()
306
+	seen := map[int64]struct{}{}
307
+	out := make([]profileContributionRepo, 0, 64)
308
+	add := func(ownerSlug string, repo reposdb.Repo) {
309
+		if ownerSlug == "" {
310
+			return
311
+		}
312
+		if _, ok := seen[repo.ID]; ok {
313
+			return
314
+		}
315
+		if !policy.IsVisibleTo(ctx, deps, actor, policy.NewRepoRefFromRepo(repo)) {
316
+			return
317
+		}
318
+		seen[repo.ID] = struct{}{}
319
+		out = append(out, profileContributionRepo{Repo: repo, OwnerSlug: ownerSlug})
320
+	}
321
+
322
+	userRepos, err := queries.ListReposForOwnerUser(ctx, h.d.Pool, pgtype.Int8{Int64: user.ID, Valid: true})
323
+	if err != nil {
324
+		h.d.Logger.WarnContext(ctx, "profile overview: contribution user repos", "user_id", user.ID, "error", err)
325
+	} else {
326
+		for _, repo := range userRepos {
327
+			add(user.Username, repo)
328
+		}
329
+	}
330
+
331
+	orgRows, err := orgsdb.New().ListOrgsForUser(ctx, h.d.Pool, user.ID)
332
+	if err != nil {
333
+		h.d.Logger.WarnContext(ctx, "profile overview: contribution orgs", "user_id", user.ID, "error", err)
334
+	} else {
335
+		for _, org := range orgRows {
336
+			orgRepos, err := queries.ListReposForOwnerOrg(ctx, h.d.Pool, pgtype.Int8{Int64: org.OrgID, Valid: true})
337
+			if err != nil {
338
+				h.d.Logger.WarnContext(ctx, "profile overview: contribution org repos", "org_id", org.OrgID, "error", err)
339
+				continue
340
+			}
341
+			for _, repo := range orgRepos {
342
+				add(org.Slug, repo)
343
+			}
344
+		}
345
+	}
346
+
347
+	publicRepos, err := queries.ListPublicContributionRepos(ctx, h.d.Pool, int32(profileContribRepoLimit))
348
+	if err != nil {
349
+		h.d.Logger.WarnContext(ctx, "profile overview: contribution public repos", "user_id", user.ID, "error", err)
350
+		return out
351
+	}
352
+	for _, row := range publicRepos {
353
+		add(row.OwnerSlug, row.Repo)
354
+	}
355
+	return out
356
+}
357
+
358
+func selectedContributionYear(query url.Values, currentYear int) int {
359
+	for _, key := range []string{"year", "from"} {
360
+		raw := strings.TrimSpace(query.Get(key))
361
+		if len(raw) >= 4 {
362
+			raw = raw[:4]
363
+		}
364
+		year, err := strconv.Atoi(raw)
365
+		if err == nil && year >= currentYear-100 && year <= currentYear {
366
+			return year
367
+		}
368
+	}
369
+	return currentYear
370
+}
371
+
372
+func contributionYears(username string, currentYear, selectedYear int) []contributionYear {
373
+	years := make([]contributionYear, 0, 4)
374
+	base := "/" + url.PathEscape(username)
375
+	for i := 0; i < 4; i++ {
376
+		year := currentYear - i
377
+		href := base
378
+		if year != currentYear {
379
+			href += "?year=" + strconv.Itoa(year)
380
+		}
381
+		years = append(years, contributionYear{
382
+			Year:   year,
383
+			Href:   href,
384
+			Active: year == selectedYear,
385
+		})
386
+	}
387
+	return years
388
+}
389
+
390
+func commitMatchesProfileUser(commit repogit.Commit, verifiedEmails map[string]struct{}, user usersdb.User) bool {
391
+	if len(verifiedEmails) > 0 {
392
+		_, ok := verifiedEmails[strings.ToLower(strings.TrimSpace(commit.AuthorEmail))]
393
+		return ok
394
+	}
395
+	name := strings.ToLower(strings.TrimSpace(commit.AuthorName))
396
+	if name == "" {
397
+		return false
398
+	}
399
+	if name == strings.ToLower(user.Username) {
400
+		return true
401
+	}
402
+	return user.DisplayName != "" && name == strings.ToLower(strings.TrimSpace(user.DisplayName))
403
+}
404
+
276405
 func (h *Handlers) verifiedEmails(ctx context.Context, userID int64) map[string]struct{} {
277406
 	rows, err := h.q.ListUserEmailsForUser(ctx, h.d.Pool, userID)
278407
 	if err != nil {
@@ -303,13 +432,29 @@ func contributionLevel(count int) int {
303432
 }
304433
 
305434
 func contributionDayTitle(count int, day time.Time) string {
435
+	date := day.Format("January ") + ordinalDay(day.Day()) + "."
306436
 	if count == 0 {
307
-		return "No contributions on " + day.Format("January 2") + "."
437
+		return "No contributions on " + date
308438
 	}
309439
 	if count == 1 {
310
-		return "1 contribution on " + day.Format("January 2") + "."
440
+		return "1 contribution on " + date
441
+	}
442
+	return strconv.Itoa(count) + " contributions on " + date
443
+}
444
+
445
+func ordinalDay(day int) string {
446
+	suffix := "th"
447
+	if day%100 < 11 || day%100 > 13 {
448
+		switch day % 10 {
449
+		case 1:
450
+			suffix = "st"
451
+		case 2:
452
+			suffix = "nd"
453
+		case 3:
454
+			suffix = "rd"
455
+		}
311456
 	}
312
-	return strconv.Itoa(count) + " contributions on " + day.Format("January 2") + "."
457
+	return strconv.Itoa(day) + suffix
313458
 }
314459
 
315460
 func profileCodeRouteBase(owner, repoName, route, ref, dir string) string {
internal/web/handlers/profile/profile.gomodified
@@ -183,7 +183,7 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
183183
 		"Orgs":             h.profileOrganizations(r.Context(), user.ID),
184184
 		"ProfileReadme":    readme,
185185
 		"HasProfileReadme": hasReadme,
186
-		"Contributions":    h.contributionCalendar(r.Context(), user, visibleRepos),
186
+		"Contributions":    h.contributionCalendar(r.Context(), user, viewer, r.URL.Query()),
187187
 		"PinnedRepos":      pinnedRepos,
188188
 		"PinCandidates":    pinCandidates,
189189
 		"PinsRemaining":    profilePinsRemaining(pinCandidates),
internal/web/handlers/profile/profile_test.gomodified
@@ -15,12 +15,14 @@ import (
1515
 	"strings"
1616
 	"testing"
1717
 	"testing/fstest"
18
+	"time"
1819
 
1920
 	"github.com/go-chi/chi/v5"
2021
 	"github.com/jackc/pgx/v5/pgxpool"
2122
 
2223
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
2324
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
25
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
2426
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
2527
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
2628
 	profileh "github.com/tenseleyFlow/shithub/internal/web/handlers/profile"
@@ -29,9 +31,10 @@ import (
2931
 )
3032
 
3133
 type profileEnv struct {
32
-	srv  *httptest.Server
33
-	pool *pgxpool.Pool
34
-	q    *usersdb.Queries
34
+	srv    *httptest.Server
35
+	pool   *pgxpool.Pool
36
+	q      *usersdb.Queries
37
+	repoFS *storage.RepoFS
3538
 }
3639
 
3740
 func setupProfileEnv(t *testing.T) *profileEnv {
@@ -39,13 +42,26 @@ func setupProfileEnv(t *testing.T) *profileEnv {
3942
 }
4043
 
4144
 func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *profileEnv {
45
+	return setupProfileEnvWithDeps(t, objectStore, nil)
46
+}
47
+
48
+func setupProfileEnvWithRepoFS(t *testing.T) *profileEnv {
49
+	t.Helper()
50
+	repoFS, err := storage.NewRepoFS(t.TempDir())
51
+	if err != nil {
52
+		t.Fatalf("NewRepoFS: %v", err)
53
+	}
54
+	return setupProfileEnvWithDeps(t, nil, repoFS)
55
+}
56
+
57
+func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repoFS *storage.RepoFS) *profileEnv {
4258
 	t.Helper()
4359
 	pool := dbtest.NewTestDB(t)
4460
 
4561
 	tmplFS := fstest.MapFS{
4662
 		"_layout.html":           {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
4763
 		"hello.html":             {Data: []byte(`{{ define "page" }}home{{ end }}`)},
48
-		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
64
+		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
4965
 		"profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
5066
 		"orgs/profile.html":      {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
5167
 		"orgs/repositories.html": {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)},
@@ -60,6 +76,7 @@ func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *pr
6076
 	h, err := profileh.New(profileh.Deps{
6177
 		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
6278
 		Render: rr, Pool: pool,
79
+		RepoFS:      repoFS,
6380
 		ObjectStore: objectStore,
6481
 	})
6582
 	if err != nil {
@@ -94,7 +111,7 @@ func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *pr
94111
 
95112
 	srv := httptest.NewServer(r)
96113
 	t.Cleanup(srv.Close)
97
-	return &profileEnv{srv: srv, pool: pool, q: usersdb.New()}
114
+	return &profileEnv{srv: srv, pool: pool, q: usersdb.New(), repoFS: repoFS}
98115
 }
99116
 
100117
 // fixtureHash is a static PHC test fixture (zero salt, zero key) — not a real credential.
@@ -121,6 +138,18 @@ func (e *profileEnv) insertUser(t *testing.T, username, display, bio string) use
121138
 	return user
122139
 }
123140
 
141
+func (e *profileEnv) insertVerifiedEmail(t *testing.T, userID int64, email string) {
142
+	t.Helper()
143
+	if _, err := e.q.CreateUserEmail(context.Background(), e.pool, usersdb.CreateUserEmailParams{
144
+		UserID:    userID,
145
+		Email:     email,
146
+		IsPrimary: true,
147
+		Verified:  true,
148
+	}); err != nil {
149
+		t.Fatalf("CreateUserEmail: %v", err)
150
+	}
151
+}
152
+
124153
 func (e *profileEnv) insertOrg(t *testing.T, slug, display, desc string, creator usersdb.User) int64 {
125154
 	t.Helper()
126155
 	ctx := context.Background()
@@ -180,6 +209,37 @@ func (e *profileEnv) insertUserRepo(t *testing.T, userID int64, name, desc, visi
180209
 	return repoID
181210
 }
182211
 
212
+func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
213
+	t.Helper()
214
+	if e.repoFS == nil {
215
+		t.Fatal("repoFS not configured")
216
+	}
217
+	ctx := context.Background()
218
+	gitDir, err := e.repoFS.RepoPath(owner, repoName)
219
+	if err != nil {
220
+		t.Fatalf("RepoPath: %v", err)
221
+	}
222
+	if err := e.repoFS.InitBare(ctx, gitDir); err != nil {
223
+		t.Fatalf("InitBare: %v", err)
224
+	}
225
+	oid, err := repogit.InitialCommit{
226
+		GitDir:      gitDir,
227
+		AuthorName:  authorName,
228
+		AuthorEmail: authorEmail,
229
+		Message:     "Initial commit",
230
+		Branch:      "trunk",
231
+		When:        when,
232
+		Files: []repogit.FileEntry{{
233
+			Path: "README.md",
234
+			Body: []byte("# " + repoName + "\n"),
235
+		}},
236
+	}.Build(ctx)
237
+	if err != nil {
238
+		t.Fatalf("InitialCommit.Build: %v", err)
239
+	}
240
+	return oid
241
+}
242
+
183243
 func (e *profileEnv) insertRedirect(t *testing.T, oldname string, userID int64) {
184244
 	t.Helper()
185245
 	if _, err := e.pool.Exec(context.Background(),
@@ -304,6 +364,58 @@ func TestProfile_OverviewDataUsesVisibleReposAndOrganizations(t *testing.T) {
304364
 	}
305365
 }
306366
 
367
+func TestProfile_ContributionsCountVerifiedEmailAcrossUserAndOrgRepos(t *testing.T) {
368
+	t.Parallel()
369
+	env := setupProfileEnvWithRepoFS(t)
370
+	alice := env.insertUser(t, "alice", "Alice Anderson", "Hi.")
371
+	env.insertVerifiedEmail(t, alice.ID, "alice@example.com")
372
+	env.insertUserRepo(t, alice.ID, "owned", "user repo", "public", "Go", 0, 0)
373
+	orgID := env.insertOrg(t, "acme", "Acme", "", alice)
374
+	env.insertOrgRepo(t, orgID, "team", "org repo", "public", "Rust", 0, 0)
375
+	env.insertOrgRepo(t, orgID, "other", "different author", "public", "Rust", 0, 0)
376
+
377
+	now := time.Now().UTC()
378
+	env.writeInitialCommit(t, "alice", "owned", "Alice Anderson", "alice@example.com", now.AddDate(0, 0, -7))
379
+	env.writeInitialCommit(t, "acme", "team", "A. Alice", "alice@example.com", now.AddDate(0, 0, -14))
380
+	env.writeInitialCommit(t, "acme", "other", "Bob", "bob@example.com", now.AddDate(0, 0, -5))
381
+
382
+	body := env.getAs(t, "/alice", usersdb.User{})
383
+	for _, want := range []string{
384
+		"CONTRIB=2",
385
+		"PERIOD=in the last year",
386
+		"WEEKS=53",
387
+	} {
388
+		if !strings.Contains(body, want) {
389
+			t.Errorf("missing %q in body: %s", want, body)
390
+		}
391
+	}
392
+}
393
+
394
+func TestProfile_ContributionsSelectedYearHasStableLinks(t *testing.T) {
395
+	t.Parallel()
396
+	env := setupProfileEnvWithRepoFS(t)
397
+	alice := env.insertUser(t, "alice", "Alice Anderson", "")
398
+	env.insertVerifiedEmail(t, alice.ID, "alice@example.com")
399
+	env.insertUserRepo(t, alice.ID, "archive", "old work", "public", "Go", 0, 0)
400
+
401
+	currentYear := time.Now().UTC().Year()
402
+	selectedYear := currentYear - 1
403
+	env.writeInitialCommit(t, "alice", "archive", "Alice Anderson", "alice@example.com",
404
+		time.Date(selectedYear, time.March, 10, 12, 0, 0, 0, time.UTC))
405
+
406
+	body := env.getAs(t, fmt.Sprintf("/alice?year=%d", selectedYear), usersdb.User{})
407
+	for _, want := range []string{
408
+		"CONTRIB=1",
409
+		"PERIOD=in " + strconv.Itoa(selectedYear),
410
+		fmt.Sprintf("%d:false:/alice;", currentYear),
411
+		fmt.Sprintf("%d:true:/alice?year=%d;", selectedYear, selectedYear),
412
+	} {
413
+		if !strings.Contains(body, want) {
414
+			t.Errorf("missing %q in body: %s", want, body)
415
+		}
416
+	}
417
+}
418
+
307419
 func TestProfile_UnknownUser404(t *testing.T) {
308420
 	t.Parallel()
309421
 	env := setupProfileEnv(t)
internal/web/static/css/shithub.cssmodified
@@ -1248,6 +1248,7 @@ code {
12481248
   border-radius: 6px;
12491249
   padding: 1rem;
12501250
   min-width: 0;
1251
+  overflow-x: auto;
12511252
 }
12521253
 .shithub-contrib-months {
12531254
   display: grid;
@@ -1258,6 +1259,7 @@ code {
12581259
   color: var(--fg-default);
12591260
   font-size: 0.75rem;
12601261
   line-height: 1;
1262
+  width: max-content;
12611263
 }
12621264
 .shithub-contrib-months span {
12631265
   min-height: 1rem;
@@ -1267,6 +1269,7 @@ code {
12671269
   display: flex;
12681270
   gap: 0.5rem;
12691271
   min-width: 0;
1272
+  width: max-content;
12701273
 }
12711274
 .shithub-contrib-weekdays {
12721275
   display: grid;
@@ -1280,7 +1283,7 @@ code {
12801283
 .shithub-contrib-weeks {
12811284
   display: flex;
12821285
   gap: 3px;
1283
-  overflow-x: auto;
1286
+  overflow: visible;
12841287
   padding-bottom: 0.25rem;
12851288
 }
12861289
 .shithub-contrib-week {
@@ -1296,6 +1299,49 @@ code {
12961299
   border-radius: 2px;
12971300
   box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
12981301
 }
1302
+.shithub-contrib-day {
1303
+  position: relative;
1304
+  cursor: default;
1305
+}
1306
+.shithub-contrib-day:focus {
1307
+  outline: 2px solid var(--accent-fg);
1308
+  outline-offset: 1px;
1309
+}
1310
+.shithub-contrib-day:hover::before,
1311
+.shithub-contrib-day:focus::before {
1312
+  content: "";
1313
+  position: absolute;
1314
+  z-index: 31;
1315
+  left: 50%;
1316
+  bottom: calc(100% + 2px);
1317
+  width: 0;
1318
+  height: 0;
1319
+  border: 5px solid transparent;
1320
+  border-top-color: var(--fg-muted);
1321
+  transform: translateX(-50%);
1322
+  pointer-events: none;
1323
+}
1324
+.shithub-contrib-day:hover::after,
1325
+.shithub-contrib-day:focus::after {
1326
+  content: attr(data-title);
1327
+  position: absolute;
1328
+  z-index: 30;
1329
+  left: 50%;
1330
+  bottom: calc(100% + 11px);
1331
+  max-width: min(260px, 80vw);
1332
+  padding: 0.35rem 0.55rem;
1333
+  border-radius: 6px;
1334
+  background: var(--fg-muted);
1335
+  color: #fff;
1336
+  box-shadow: 0 8px 18px rgba(1,4,9,0.35);
1337
+  font-size: 0.75rem;
1338
+  font-weight: 600;
1339
+  line-height: 1.25;
1340
+  text-align: center;
1341
+  white-space: nowrap;
1342
+  transform: translateX(-50%);
1343
+  pointer-events: none;
1344
+}
12991345
 .shithub-contrib-day.level-0,
13001346
 .shithub-contrib-legend .level-0 {
13011347
   background: #161b22;
@@ -1350,11 +1396,18 @@ code {
13501396
   display: grid;
13511397
   gap: 0.5rem;
13521398
 }
1353
-.shithub-profile-years span {
1399
+.shithub-profile-years span,
1400
+.shithub-profile-years a {
13541401
   display: block;
13551402
   padding: 0.65rem 1rem;
13561403
   border-radius: 6px;
13571404
   color: var(--fg-muted);
1405
+  text-decoration: none;
1406
+}
1407
+.shithub-profile-years a:hover {
1408
+  color: var(--fg-default);
1409
+  background: var(--canvas-subtle);
1410
+  text-decoration: none;
13581411
 }
13591412
 .shithub-profile-years .is-active {
13601413
   color: #fff;
internal/web/templates/profile/view.htmlmodified
@@ -100,7 +100,7 @@
100100
 
101101
       <section class="shithub-profile-contributions" aria-labelledby="contrib-h">
102102
         <div class="shithub-profile-contrib-head">
103
-          <h2 id="contrib-h">{{ .Contributions.Total }} contribution{{ pluralize .Contributions.Total "" "s" }} in the last year</h2>
103
+          <h2 id="contrib-h">{{ .Contributions.Total }} contribution{{ pluralize .Contributions.Total "" "s" }} {{ .Contributions.Period }}</h2>
104104
           <details class="shithub-profile-contrib-settings">
105105
             <summary>Contribution settings {{ octicon "triangle-down" }}</summary>
106106
             <div>
@@ -113,7 +113,7 @@
113113
         </div>
114114
 
115115
         <div class="shithub-profile-contrib-layout">
116
-          <div class="shithub-profile-calendar" role="img" aria-label="{{ .Contributions.Total }} contributions in the last year">
116
+          <div class="shithub-profile-calendar" role="img" aria-label="{{ .Contributions.Total }} contributions {{ .Contributions.Period }}">
117117
             <div class="shithub-contrib-months" aria-hidden="true">
118118
               {{ range .Contributions.Weeks }}<span>{{ .MonthLabel }}</span>{{ end }}
119119
             </div>
@@ -125,7 +125,7 @@
125125
                 {{ range .Contributions.Weeks }}
126126
                 <div class="shithub-contrib-week">
127127
                   {{ range .Days }}
128
-                  <span class="shithub-contrib-day level-{{ .Level }}{{ if .IsFuture }} is-future{{ end }}{{ if not .IsInWindow }} is-outside{{ end }}" title="{{ .Title }}" aria-label="{{ .Title }}"></span>
128
+                  <span class="shithub-contrib-day level-{{ .Level }}{{ if .IsFuture }} is-future{{ end }}{{ if not .IsInWindow }} is-outside{{ end }}" title="{{ .Title }}" data-title="{{ .Title }}" data-date="{{ .Date }}" data-count="{{ .Count }}" aria-label="{{ .Title }}" tabindex="0"></span>
129129
                   {{ end }}
130130
                 </div>
131131
                 {{ end }}
@@ -138,7 +138,13 @@
138138
           </div>
139139
           <ol class="shithub-profile-years" aria-label="Contribution years">
140140
             {{ range .Contributions.Years }}
141
-            <li><span class="{{ if eq . $.Contributions.CurrentYear }}is-active{{ end }}">{{ . }}</span></li>
141
+            <li>
142
+              {{ if .Active }}
143
+              <span class="is-active" aria-current="page">{{ .Year }}</span>
144
+              {{ else }}
145
+              <a href="{{ .Href }}">{{ .Year }}</a>
146
+              {{ end }}
147
+            </li>
142148
             {{ end }}
143149
           </ol>
144150
         </div>
@@ -153,7 +159,7 @@
153159
             {{ if .Contributions.MonthCommitCount }}
154160
             <strong>Created {{ .Contributions.MonthCommitCount }} commit{{ pluralize .Contributions.MonthCommitCount "" "s" }} in {{ .Contributions.MonthRepoCount }} repositor{{ pluralize .Contributions.MonthRepoCount "y" "ies" }}</strong>
155161
             {{ else }}
156
-            <strong>No public activity yet this month</strong>
162
+            <strong>No activity in {{ .Contributions.MonthLabel }}</strong>
157163
             {{ end }}
158164
           </div>
159165
         </div>