tenseleyflow/shithub / 98a7979

Browse files

Build commits page view model

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
98a79793e6db6bd2b1256c20bc4c63a70f34e9ce
Parents
097a44b
Tree
c69729b

2 changed files

StatusFile+-
M internal/web/handlers/repo/history.go 411 21
A internal/web/handlers/repo/history_commits_test.go 156 0
internal/web/handlers/repo/history.gomodified
@@ -6,7 +6,9 @@ import (
66
 	"errors"
77
 	"html/template"
88
 	"net/http"
9
+	"net/url"
910
 	"regexp"
11
+	"sort"
1012
 	"strconv"
1113
 	"strings"
1214
 	"time"
@@ -84,8 +86,10 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) {
8486
 	}
8587
 	pathFilter := strings.TrimSpace(q.Get("path"))
8688
 	authorFilter := strings.TrimSpace(q.Get("author"))
87
-	since := parseDateParam(q.Get("since"))
88
-	until := parseDateParam(q.Get("until"))
89
+	sinceRaw := strings.TrimSpace(q.Get("since"))
90
+	untilRaw := strings.TrimSpace(q.Get("until"))
91
+	since := parseDateParam(sinceRaw)
92
+	until := parseUntilDateParam(untilRaw)
8993
 
9094
 	commits, err := git.Log(r.Context(), gitDir, git.LogOptions{
9195
 		Ref:      ref,
@@ -106,26 +110,61 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) {
106110
 	resolver := identity.New(h.d.Pool)
107111
 	rows := make([]commitRow, 0, len(commits))
108112
 	for _, c := range commits {
109
-		rows = append(rows, commitRow{Commit: c, Author: resolver.Resolve(r.Context(), c.AuthorEmail)})
113
+		rows = append(rows, newCommitRow(c, resolver.Resolve(r.Context(), c.AuthorEmail)))
114
+	}
115
+
116
+	filterValues := commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw)
117
+	olderHref := ""
118
+	if len(commits) == perPage {
119
+		v := cloneURLValues(filterValues)
120
+		v.Set("page", strconv.Itoa(page+1))
121
+		olderHref = commitsHref(owner.Username, row.Name, ref, v)
122
+	}
123
+	newerHref := ""
124
+	if page > 1 {
125
+		v := cloneURLValues(filterValues)
126
+		v.Set("page", strconv.Itoa(page-1))
127
+		newerHref = commitsHref(owner.Username, row.Name, ref, v)
128
+	}
129
+	pathClearHref := ""
130
+	if pathFilter != "" {
131
+		v := cloneURLValues(filterValues)
132
+		v.Del("path")
133
+		pathClearHref = commitsHref(owner.Username, row.Name, ref, v)
134
+	}
135
+	allAuthorsValues := cloneURLValues(filterValues)
136
+	allAuthorsValues.Del("author")
137
+	selectedDate := until
138
+	if selectedDate.IsZero() {
139
+		selectedDate = since
110140
 	}
111141
 
112142
 	h.d.Render.RenderPage(w, r, "repo/commits", map[string]any{
113
-		"Title":      "Commits · " + row.Name,
114
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
115
-		"Owner":      owner.Username,
116
-		"Repo":       row,
117
-		"Ref":        ref,
118
-		"PathFilter": pathFilter,
119
-		"Author":     authorFilter,
120
-		"Since":      q.Get("since"),
121
-		"Until":      q.Get("until"),
122
-		"Rows":       rows,
123
-		"Page":       page,
124
-		"NextPage":   page + 1,
125
-		"PrevPage":   page - 1,
126
-		"HasMore":    len(commits) == perPage,
127
-		"Branches":   refs.Branches,
128
-		"Tags":       refs.Tags,
143
+		"Title":            "Commits · " + row.Name,
144
+		"CSRFToken":        middleware.CSRFTokenForRequest(r),
145
+		"Owner":            owner.Username,
146
+		"Repo":             row,
147
+		"RepoActions":      h.repoActions(r, row.ID),
148
+		"RepoCounts":       h.subnavCounts(r.Context(), row.ID, row.ForkCount),
149
+		"CanSettings":      h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
150
+		"ActiveSubnav":     "code",
151
+		"Ref":              ref,
152
+		"PathFilter":       pathFilter,
153
+		"PathClearHref":    pathClearHref,
154
+		"Author":           authorFilter,
155
+		"AuthorLabel":      commitAuthorSummary(rows, authorFilter),
156
+		"AllAuthorsHref":   commitsHref(owner.Username, row.Name, ref, allAuthorsValues),
157
+		"AuthorFilters":    commitAuthorFilters(owner.Username, row.Name, ref, filterValues, rows, authorFilter),
158
+		"Since":            sinceRaw,
159
+		"Until":            untilRaw,
160
+		"DateLabel":        commitDateSummary(sinceRaw, untilRaw),
161
+		"Calendar":         commitCalendar(owner.Username, row.Name, ref, filterValues, selectedDate, q.Get("calendar_month"), rows, time.Now()),
162
+		"CommitGroups":     groupCommitRows(rows),
163
+		"RefMenu":          commitRefMenu(owner.Username, row.Name, ref, row.DefaultBranch, refs),
164
+		"Page":             page,
165
+		"NewerHref":        newerHref,
166
+		"OlderHref":        olderHref,
167
+		"HasActiveFilters": pathFilter != "" || authorFilter != "" || sinceRaw != "" || untilRaw != "",
129168
 	})
130169
 }
131170
 
@@ -267,8 +306,64 @@ func (h *Handlers) commitsAtom(w http.ResponseWriter, r *http.Request) {
267306
 // git data so templates can render avatars and profile links without
268307
 // re-running the resolver.
269308
 type commitRow struct {
270
-	Commit git.Commit
271
-	Author identity.Resolved
309
+	Commit      git.Commit
310
+	Author      identity.Resolved
311
+	AuthorLabel string
312
+	AuthorHref  string
313
+}
314
+
315
+func newCommitRow(c git.Commit, author identity.Resolved) commitRow {
316
+	return commitRow{
317
+		Commit:      c,
318
+		Author:      author,
319
+		AuthorLabel: commitAuthorLabel(c, author),
320
+		AuthorHref:  commitAuthorHref(author),
321
+	}
322
+}
323
+
324
+type commitGroup struct {
325
+	Title string
326
+	Rows  []commitRow
327
+}
328
+
329
+type commitAuthorFilter struct {
330
+	Label         string
331
+	Query         string
332
+	Href          string
333
+	Active        bool
334
+	User          bool
335
+	AvatarURL     string
336
+	IdenticonSeed string
337
+}
338
+
339
+type commitRefOption struct {
340
+	Name      string
341
+	Href      string
342
+	Active    bool
343
+	IsDefault bool
344
+}
345
+
346
+type commitRefMenuView struct {
347
+	Branches []commitRefOption
348
+	Tags     []commitRefOption
349
+}
350
+
351
+type commitCalendarView struct {
352
+	MonthLabel    string
353
+	YearLabel     string
354
+	PrevMonthHref string
355
+	NextMonthHref string
356
+	ClearHref     string
357
+	TodayHref     string
358
+	Weeks         [][]commitCalendarDay
359
+}
360
+
361
+type commitCalendarDay struct {
362
+	Label      string
363
+	Href       string
364
+	InMonth    bool
365
+	IsSelected bool
366
+	IsToday    bool
272367
 }
273368
 
274369
 type blameChunkRow struct {
@@ -276,6 +371,293 @@ type blameChunkRow struct {
276371
 	Author identity.Resolved
277372
 }
278373
 
374
+func commitAuthorLabel(c git.Commit, author identity.Resolved) string {
375
+	if author.User {
376
+		if author.Username != "" {
377
+			return author.Username
378
+		}
379
+		return author.DisplayName
380
+	}
381
+	if strings.TrimSpace(c.AuthorName) != "" {
382
+		return strings.TrimSpace(c.AuthorName)
383
+	}
384
+	return strings.TrimSpace(c.AuthorEmail)
385
+}
386
+
387
+func commitAuthorHref(author identity.Resolved) string {
388
+	if !author.User || author.Username == "" {
389
+		return ""
390
+	}
391
+	return "/" + pathEscapeSegments(author.Username)
392
+}
393
+
394
+func commitAuthorQuery(row commitRow) string {
395
+	if row.Author.User && row.Author.Username != "" {
396
+		return row.Author.Username
397
+	}
398
+	if email := strings.TrimSpace(row.Commit.AuthorEmail); email != "" {
399
+		return email
400
+	}
401
+	return strings.TrimSpace(row.Commit.AuthorName)
402
+}
403
+
404
+func groupCommitRows(rows []commitRow) []commitGroup {
405
+	groups := make([]commitGroup, 0)
406
+	last := ""
407
+	for _, row := range rows {
408
+		title := row.Commit.AuthorWhen.Local().Format("January 2, 2006")
409
+		if title != last {
410
+			groups = append(groups, commitGroup{Title: title})
411
+			last = title
412
+		}
413
+		groups[len(groups)-1].Rows = append(groups[len(groups)-1].Rows, row)
414
+	}
415
+	return groups
416
+}
417
+
418
+func commitAuthorSummary(rows []commitRow, active string) string {
419
+	active = strings.TrimSpace(active)
420
+	if active == "" {
421
+		return "All users"
422
+	}
423
+	for _, row := range rows {
424
+		if strings.EqualFold(commitAuthorQuery(row), active) || strings.EqualFold(row.AuthorLabel, active) {
425
+			return row.AuthorLabel
426
+		}
427
+	}
428
+	return active
429
+}
430
+
431
+func commitAuthorFilters(owner, repoName, ref string, base url.Values, rows []commitRow, active string) []commitAuthorFilter {
432
+	type candidate struct {
433
+		filter commitAuthorFilter
434
+		key    string
435
+	}
436
+	seen := make(map[string]struct{})
437
+	candidates := make([]candidate, 0, len(rows))
438
+	for _, row := range rows {
439
+		query := commitAuthorQuery(row)
440
+		if query == "" {
441
+			continue
442
+		}
443
+		key := strings.ToLower(query)
444
+		if _, ok := seen[key]; ok {
445
+			continue
446
+		}
447
+		seen[key] = struct{}{}
448
+		values := cloneURLValues(base)
449
+		values.Set("author", query)
450
+		values.Del("page")
451
+		values.Del("calendar_month")
452
+		candidates = append(candidates, candidate{
453
+			key: key,
454
+			filter: commitAuthorFilter{
455
+				Label:         row.AuthorLabel,
456
+				Query:         query,
457
+				Href:          commitsHref(owner, repoName, ref, values),
458
+				Active:        strings.EqualFold(active, query) || strings.EqualFold(active, row.AuthorLabel),
459
+				User:          row.Author.User,
460
+				AvatarURL:     row.Author.AvatarURL,
461
+				IdenticonSeed: row.Author.IdenticonSeed,
462
+			},
463
+		})
464
+	}
465
+	if active != "" {
466
+		key := strings.ToLower(active)
467
+		if _, ok := seen[key]; !ok {
468
+			values := cloneURLValues(base)
469
+			values.Set("author", active)
470
+			values.Del("page")
471
+			values.Del("calendar_month")
472
+			candidates = append(candidates, candidate{
473
+				key: key,
474
+				filter: commitAuthorFilter{
475
+					Label:  active,
476
+					Query:  active,
477
+					Href:   commitsHref(owner, repoName, ref, values),
478
+					Active: true,
479
+				},
480
+			})
481
+		}
482
+	}
483
+	sort.SliceStable(candidates, func(i, j int) bool {
484
+		if candidates[i].filter.Active != candidates[j].filter.Active {
485
+			return candidates[i].filter.Active
486
+		}
487
+		return strings.ToLower(candidates[i].filter.Label) < strings.ToLower(candidates[j].filter.Label)
488
+	})
489
+	out := make([]commitAuthorFilter, 0, len(candidates))
490
+	for _, c := range candidates {
491
+		out = append(out, c.filter)
492
+	}
493
+	return out
494
+}
495
+
496
+func commitDateSummary(sinceRaw, untilRaw string) string {
497
+	sinceRaw = strings.TrimSpace(sinceRaw)
498
+	untilRaw = strings.TrimSpace(untilRaw)
499
+	switch {
500
+	case sinceRaw == "" && untilRaw == "":
501
+		return "All time"
502
+	case sinceRaw != "" && untilRaw != "":
503
+		return formatCommitFilterDate(sinceRaw) + " - " + formatCommitFilterDate(untilRaw)
504
+	case sinceRaw != "":
505
+		return "Since " + formatCommitFilterDate(sinceRaw)
506
+	default:
507
+		return "Until " + formatCommitFilterDate(untilRaw)
508
+	}
509
+}
510
+
511
+func formatCommitFilterDate(raw string) string {
512
+	t, err := time.Parse("2006-01-02", raw)
513
+	if err != nil {
514
+		return raw
515
+	}
516
+	return t.Format("Jan 2, 2006")
517
+}
518
+
519
+func commitRefMenu(owner, repoName, current, defaultBranch string, refs git.RefListing) commitRefMenuView {
520
+	out := commitRefMenuView{
521
+		Branches: make([]commitRefOption, 0, len(refs.Branches)),
522
+		Tags:     make([]commitRefOption, 0, len(refs.Tags)),
523
+	}
524
+	for _, ref := range refs.Branches {
525
+		out.Branches = append(out.Branches, commitRefOption{
526
+			Name:      ref.Name,
527
+			Href:      commitsHref(owner, repoName, ref.Name, nil),
528
+			Active:    ref.Name == current,
529
+			IsDefault: ref.Name == defaultBranch,
530
+		})
531
+	}
532
+	for _, ref := range refs.Tags {
533
+		out.Tags = append(out.Tags, commitRefOption{
534
+			Name:   ref.Name,
535
+			Href:   commitsHref(owner, repoName, ref.Name, nil),
536
+			Active: ref.Name == current,
537
+		})
538
+	}
539
+	return out
540
+}
541
+
542
+func commitCalendar(owner, repoName, ref string, base url.Values, selected time.Time, monthParam string, rows []commitRow, now time.Time) commitCalendarView {
543
+	if now.IsZero() {
544
+		now = time.Now()
545
+	}
546
+	anchor := selected
547
+	if anchor.IsZero() && len(rows) > 0 {
548
+		anchor = rows[0].Commit.AuthorWhen
549
+	}
550
+	if anchor.IsZero() {
551
+		anchor = now
552
+	}
553
+	if t, err := time.Parse("2006-01", strings.TrimSpace(monthParam)); err == nil {
554
+		anchor = t
555
+	}
556
+	loc := anchor.Location()
557
+	if loc == nil {
558
+		loc = time.Local
559
+	}
560
+	monthStart := time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, loc)
561
+	gridStart := monthStart.AddDate(0, 0, -int(monthStart.Weekday()))
562
+	weeks := make([][]commitCalendarDay, 6)
563
+	for week := 0; week < 6; week++ {
564
+		weeks[week] = make([]commitCalendarDay, 7)
565
+		for day := 0; day < 7; day++ {
566
+			d := gridStart.AddDate(0, 0, week*7+day)
567
+			values := cloneURLValues(base)
568
+			values.Set("until", d.Format("2006-01-02"))
569
+			values.Del("page")
570
+			values.Del("calendar_month")
571
+			weeks[week][day] = commitCalendarDay{
572
+				Label:      strconv.Itoa(d.Day()),
573
+				Href:       commitsHref(owner, repoName, ref, values),
574
+				InMonth:    d.Month() == monthStart.Month(),
575
+				IsSelected: sameCalendarDate(d, selected),
576
+				IsToday:    sameCalendarDate(d, now),
577
+			}
578
+		}
579
+	}
580
+
581
+	prevValues := cloneURLValues(base)
582
+	prevValues.Set("calendar_month", monthStart.AddDate(0, -1, 0).Format("2006-01"))
583
+	prevValues.Del("page")
584
+	nextValues := cloneURLValues(base)
585
+	nextValues.Set("calendar_month", monthStart.AddDate(0, 1, 0).Format("2006-01"))
586
+	nextValues.Del("page")
587
+	clearValues := cloneURLValues(base)
588
+	clearValues.Del("since")
589
+	clearValues.Del("until")
590
+	clearValues.Del("calendar_month")
591
+	clearValues.Del("page")
592
+	todayValues := cloneURLValues(base)
593
+	todayValues.Set("until", now.Format("2006-01-02"))
594
+	todayValues.Del("calendar_month")
595
+	todayValues.Del("page")
596
+
597
+	return commitCalendarView{
598
+		MonthLabel:    monthStart.Format("January"),
599
+		YearLabel:     monthStart.Format("2006"),
600
+		PrevMonthHref: commitsHref(owner, repoName, ref, prevValues),
601
+		NextMonthHref: commitsHref(owner, repoName, ref, nextValues),
602
+		ClearHref:     commitsHref(owner, repoName, ref, clearValues),
603
+		TodayHref:     commitsHref(owner, repoName, ref, todayValues),
604
+		Weeks:         weeks,
605
+	}
606
+}
607
+
608
+func sameCalendarDate(a, b time.Time) bool {
609
+	if a.IsZero() || b.IsZero() {
610
+		return false
611
+	}
612
+	bb := b.In(a.Location())
613
+	return a.Year() == bb.Year() && a.Month() == bb.Month() && a.Day() == bb.Day()
614
+}
615
+
616
+func commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw string) url.Values {
617
+	values := url.Values{}
618
+	if pathFilter != "" {
619
+		values.Set("path", pathFilter)
620
+	}
621
+	if authorFilter != "" {
622
+		values.Set("author", authorFilter)
623
+	}
624
+	if sinceRaw != "" {
625
+		values.Set("since", sinceRaw)
626
+	}
627
+	if untilRaw != "" {
628
+		values.Set("until", untilRaw)
629
+	}
630
+	return values
631
+}
632
+
633
+func commitsHref(owner, repoName, ref string, values url.Values) string {
634
+	path := "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + "/commits/" + pathEscapeSegments(ref)
635
+	if len(values) == 0 {
636
+		return path
637
+	}
638
+	encoded := values.Encode()
639
+	if encoded == "" {
640
+		return path
641
+	}
642
+	return path + "?" + encoded
643
+}
644
+
645
+func cloneURLValues(values url.Values) url.Values {
646
+	out := make(url.Values, len(values))
647
+	for k, vv := range values {
648
+		out[k] = append([]string(nil), vv...)
649
+	}
650
+	return out
651
+}
652
+
653
+func pathEscapeSegments(s string) string {
654
+	parts := strings.Split(s, "/")
655
+	for i, part := range parts {
656
+		parts[i] = url.PathEscape(part)
657
+	}
658
+	return strings.Join(parts, "/")
659
+}
660
+
279661
 // validateSHA accepts 7..40 hex chars. Git resolves short SHAs when
280662
 // unambiguous; we cap at 40 (full).
281663
 func validateSHA(s string) bool {
@@ -299,6 +681,14 @@ func parseDateParam(s string) time.Time {
299681
 	return t
300682
 }
301683
 
684
+func parseUntilDateParam(s string) time.Time {
685
+	t := parseDateParam(s)
686
+	if t.IsZero() {
687
+		return t
688
+	}
689
+	return t.Add(24*time.Hour - time.Second)
690
+}
691
+
302692
 // linkifyCommitBody produces escaped + linkified HTML from a commit
303693
 // message body. Two transformations:
304694
 //  1. URL detection (http/https) → `<a href="...">URL</a>`
internal/web/handlers/repo/history_commits_test.goadded
@@ -0,0 +1,156 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/url"
7
+	"testing"
8
+	"time"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/repos/git"
11
+	"github.com/tenseleyFlow/shithub/internal/repos/identity"
12
+)
13
+
14
+func TestGroupCommitRowsByDay(t *testing.T) {
15
+	rows := []commitRow{
16
+		testCommitRow("a1", "Ada", "ada@example.com", time.Date(2026, 5, 10, 16, 0, 0, 0, time.UTC)),
17
+		testCommitRow("b2", "Ada", "ada@example.com", time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)),
18
+		testCommitRow("c3", "Ben", "ben@example.com", time.Date(2026, 4, 24, 18, 0, 0, 0, time.UTC)),
19
+	}
20
+
21
+	groups := groupCommitRows(rows)
22
+	if len(groups) != 2 {
23
+		t.Fatalf("got %d groups, want 2", len(groups))
24
+	}
25
+	if groups[0].Title != "May 10, 2026" {
26
+		t.Fatalf("first title = %q, want May 10, 2026", groups[0].Title)
27
+	}
28
+	if len(groups[0].Rows) != 2 {
29
+		t.Fatalf("first group rows = %d, want 2", len(groups[0].Rows))
30
+	}
31
+	if groups[1].Title != "April 24, 2026" {
32
+		t.Fatalf("second title = %q, want April 24, 2026", groups[1].Title)
33
+	}
34
+}
35
+
36
+func TestCommitAuthorFiltersDeduplicateAndPreserveQuery(t *testing.T) {
37
+	base := url.Values{
38
+		"path":  {"cmd/shithubd/main.go"},
39
+		"until": {"2026-05-10"},
40
+	}
41
+	rows := []commitRow{
42
+		newCommitRow(git.Commit{
43
+			OID:         "a1",
44
+			ShortOID:    "a1",
45
+			AuthorName:  "Matthew Forrester Wolffe",
46
+			AuthorEmail: "mfwolffe@example.com",
47
+			AuthorWhen:  time.Date(2026, 5, 10, 16, 0, 0, 0, time.UTC),
48
+			Subject:     "one",
49
+		}, identity.Resolved{User: true, Username: "mfwolffe", AvatarURL: "/avatars/mfwolffe"}),
50
+		newCommitRow(git.Commit{
51
+			OID:         "b2",
52
+			ShortOID:    "b2",
53
+			AuthorName:  "Matthew Forrester Wolffe",
54
+			AuthorEmail: "mfwolffe@example.com",
55
+			AuthorWhen:  time.Date(2026, 5, 10, 15, 0, 0, 0, time.UTC),
56
+			Subject:     "two",
57
+		}, identity.Resolved{User: true, Username: "mfwolffe", AvatarURL: "/avatars/mfwolffe"}),
58
+		newCommitRow(git.Commit{
59
+			OID:         "c3",
60
+			ShortOID:    "c3",
61
+			AuthorName:  "espandonne",
62
+			AuthorEmail: "espandonne@example.com",
63
+			AuthorWhen:  time.Date(2026, 5, 10, 14, 0, 0, 0, time.UTC),
64
+			Subject:     "three",
65
+		}, identity.Resolved{}),
66
+	}
67
+
68
+	filters := commitAuthorFilters("tenseleyFlow", "shithub", "feature/x", base, rows, "mfwolffe")
69
+	if len(filters) != 2 {
70
+		t.Fatalf("got %d filters, want 2", len(filters))
71
+	}
72
+	if !filters[0].Active || filters[0].Label != "mfwolffe" {
73
+		t.Fatalf("first filter = %+v, want active mfwolffe", filters[0])
74
+	}
75
+	u, err := url.Parse(filters[0].Href)
76
+	if err != nil {
77
+		t.Fatal(err)
78
+	}
79
+	if u.Path != "/tenseleyFlow/shithub/commits/feature/x" {
80
+		t.Fatalf("path = %q", u.Path)
81
+	}
82
+	q := u.Query()
83
+	if q.Get("author") != "mfwolffe" || q.Get("path") != "cmd/shithubd/main.go" || q.Get("until") != "2026-05-10" {
84
+		t.Fatalf("unexpected query: %s", u.RawQuery)
85
+	}
86
+}
87
+
88
+func TestCommitCalendarBuildsMonthGridAndLinks(t *testing.T) {
89
+	base := url.Values{"author": {"mfwolffe"}}
90
+	selected := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
91
+	now := time.Date(2026, 5, 10, 16, 0, 0, 0, time.UTC)
92
+
93
+	cal := commitCalendar("tenseleyFlow", "shithub", "trunk", base, selected, "", nil, now)
94
+	if cal.MonthLabel != "May" || cal.YearLabel != "2026" {
95
+		t.Fatalf("month = %s %s, want May 2026", cal.MonthLabel, cal.YearLabel)
96
+	}
97
+	if len(cal.Weeks) != 6 || len(cal.Weeks[0]) != 7 {
98
+		t.Fatalf("grid dimensions = %dx%d, want 6x7", len(cal.Weeks), len(cal.Weeks[0]))
99
+	}
100
+	if cal.Weeks[0][0].Label != "26" || cal.Weeks[0][0].InMonth {
101
+		t.Fatalf("first cell = %+v, want muted Apr 26", cal.Weeks[0][0])
102
+	}
103
+	if cal.Weeks[0][5].Label != "1" || !cal.Weeks[0][5].InMonth {
104
+		t.Fatalf("May 1 cell = %+v", cal.Weeks[0][5])
105
+	}
106
+	day := cal.Weeks[2][0]
107
+	if day.Label != "10" || !day.IsSelected || !day.IsToday {
108
+		t.Fatalf("selected day = %+v, want selected today May 10", day)
109
+	}
110
+	u, err := url.Parse(day.Href)
111
+	if err != nil {
112
+		t.Fatal(err)
113
+	}
114
+	if got := u.Query().Get("until"); got != "2026-05-10" {
115
+		t.Fatalf("until = %q, want 2026-05-10", got)
116
+	}
117
+	if got := u.Query().Get("author"); got != "mfwolffe" {
118
+		t.Fatalf("author = %q, want mfwolffe", got)
119
+	}
120
+	prev, err := url.Parse(cal.PrevMonthHref)
121
+	if err != nil {
122
+		t.Fatal(err)
123
+	}
124
+	if got := prev.Query().Get("calendar_month"); got != "2026-04" {
125
+		t.Fatalf("prev calendar_month = %q", got)
126
+	}
127
+	clear, err := url.Parse(cal.ClearHref)
128
+	if err != nil {
129
+		t.Fatal(err)
130
+	}
131
+	if clear.Query().Get("until") != "" || clear.Query().Get("since") != "" {
132
+		t.Fatalf("clear href kept date filters: %s", clear.RawQuery)
133
+	}
134
+	if got := clear.Query().Get("author"); got != "mfwolffe" {
135
+		t.Fatalf("clear author = %q, want mfwolffe", got)
136
+	}
137
+}
138
+
139
+func TestParseUntilDateParamIncludesWholeDay(t *testing.T) {
140
+	got := parseUntilDateParam("2026-05-10")
141
+	want := time.Date(2026, 5, 10, 23, 59, 59, 0, time.UTC)
142
+	if !got.Equal(want) {
143
+		t.Fatalf("parseUntilDateParam = %s, want %s", got, want)
144
+	}
145
+}
146
+
147
+func testCommitRow(oid, name, email string, when time.Time) commitRow {
148
+	return newCommitRow(git.Commit{
149
+		OID:         oid,
150
+		ShortOID:    oid,
151
+		AuthorName:  name,
152
+		AuthorEmail: email,
153
+		AuthorWhen:  when,
154
+		Subject:     oid,
155
+	}, identity.Resolved{})
156
+}