diff --git a/internal/viewer/server.go b/internal/viewer/server.go index 2f8aa076..182beea5 100644 --- a/internal/viewer/server.go +++ b/internal/viewer/server.go @@ -95,6 +95,83 @@ type SeverityCount struct { Low int } +// CategoryCount holds counts for each review comment category. +type CategoryCount struct { + Bug int + Security int + Performance int + Maintainability int + Test int + Style int + Documentation int + Other int +} + +var knownCommentCategories = map[string]struct{}{ + "bug": {}, + "security": {}, + "performance": {}, + "maintainability": {}, + "test": {}, + "style": {}, + "documentation": {}, + "other": {}, +} + +func normalizedCommentCategory(category string) string { + category = strings.ToLower(strings.TrimSpace(category)) + if _, ok := knownCommentCategories[category]; ok { + return category + } + return "other" +} + +func normalizedCommentSeverity(severity string) string { + return strings.ToLower(strings.TrimSpace(severity)) +} + +func categoryCounts(comments []*ReviewComment) CategoryCount { + var counts CategoryCount + for _, comment := range comments { + switch normalizedCommentCategory(comment.Category) { + case "bug": + counts.Bug++ + case "security": + counts.Security++ + case "performance": + counts.Performance++ + case "maintainability": + counts.Maintainability++ + case "test": + counts.Test++ + case "style": + counts.Style++ + case "documentation": + counts.Documentation++ + default: + counts.Other++ + } + } + return counts +} + +func severityCounts(comments []*ReviewComment) SeverityCount { + var counts SeverityCount + for _, comment := range comments { + switch strings.ToLower(strings.TrimSpace(comment.Severity)) { + case "critical": + counts.Critical++ + case "high": + counts.High++ + case "medium": + counts.Medium++ + case "low": + counts.Low++ + } + } + return counts +} + func parseTemplate(name string) (*template.Template, error) { funcMap := template.FuncMap{ "formatDuration": formatDuration, @@ -164,24 +241,12 @@ func parseTemplate(name string) (*template.Template, error) { } return groups }, - "severityCounts": func(comments []*ReviewComment) SeverityCount { - var sc SeverityCount - for _, c := range comments { - switch c.Severity { - case "critical": - sc.Critical++ - case "high": - sc.High++ - case "medium": - sc.Medium++ - case "low": - sc.Low++ - } - } - return sc - }, + "severityCounts": severityCounts, + "categoryCounts": categoryCounts, + "commentCategory": normalizedCommentCategory, + "commentSeverity": normalizedCommentSeverity, "severityClass": func(s string) string { - switch s { + switch normalizedCommentSeverity(s) { case "critical": return "severity-critical" case "high": @@ -195,13 +260,23 @@ func parseTemplate(name string) (*template.Template, error) { } }, "categoryClass": func(s string) string { - switch s { + switch normalizedCommentCategory(s) { case "bug": return "cat-bug" case "security": return "cat-security" case "performance": return "cat-performance" + case "maintainability": + return "cat-maintainability" + case "test": + return "cat-test" + case "style": + return "cat-style" + case "documentation": + return "cat-documentation" + case "other": + return "cat-other" default: return "cat-default" } diff --git a/internal/viewer/server_startserver_test.go b/internal/viewer/server_startserver_test.go index 1d94ef75..b79e0df4 100644 --- a/internal/viewer/server_startserver_test.go +++ b/internal/viewer/server_startserver_test.go @@ -40,8 +40,9 @@ func TestStartServer_AddrInUse(t *testing.T) { } // TestParseTemplate_SessionWithComments renders session.html with review -// comments spanning every severity and category so the funcMap closures -// (severityCounts, severityClass, categoryClass, groupCommentsByFile) execute. +// comments spanning several severities and categories so the template helpers +// (severityCounts, categoryCounts, severityClass, categoryClass, +// groupCommentsByFile, and the normalization helpers) execute. func TestParseTemplate_SessionWithComments(t *testing.T) { tmpl, err := parseTemplate("session.html") if err != nil { @@ -71,4 +72,30 @@ func TestParseTemplate_SessionWithComments(t *testing.T) { if !strings.Contains(rr.Body.String(), "Review Comments") { t.Error("rendered page missing Review Comments section") } + body := rr.Body.String() + for _, want := range []string{ + `data-filter-kind="all"`, + `data-filter-kind="severity" data-filter-value="critical"`, + `data-filter-kind="category" data-filter-value="bug"`, + `data-filter-kind="category" data-filter-value="other"`, + `data-comment-card data-category="bug" data-severity="critical"`, + `data-comment-card data-category="other" data-severity="low"`, + `data-comment-filter-empty`, + } { + if !strings.Contains(body, want) { + t.Errorf("rendered page missing %q", want) + } + } +} + +func TestCategoryCounts_NormalizesUnknownCategories(t *testing.T) { + counts := categoryCounts([]*ReviewComment{ + {Category: "bug"}, + {Category: "MAINTAINABILITY"}, + {Category: ""}, + {Category: "not-a-category"}, + }) + if counts.Bug != 1 || counts.Maintainability != 1 || counts.Other != 2 { + t.Fatalf("unexpected category counts: %+v", counts) + } } diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js index bbc970d8..33b34fce 100644 --- a/internal/viewer/static/session.js +++ b/internal/viewer/static/session.js @@ -28,3 +28,72 @@ document.querySelectorAll('.response-text').forEach(function(el) { }); el.innerHTML = html; }); + +(function() { + const filters = Array.from(document.querySelectorAll('.comment-filter-chip[data-filter-kind]')); + const groups = Array.from(document.querySelectorAll('.comment-file-group')); + const emptyState = document.querySelector('[data-comment-filter-empty]'); + + if (filters.length === 0 || groups.length === 0) { + return; + } + + let activeKind = 'all'; + let activeValue = ''; + + function cardMatches(card) { + if (activeKind === 'all') { + return true; + } + return card.dataset[activeKind] === activeValue; + } + + function updateFilterState() { + filters.forEach(function(filter) { + const isActive = activeKind === filter.dataset.filterKind && + activeValue === (filter.dataset.filterValue || ''); + filter.classList.toggle('is-active', isActive); + filter.setAttribute('aria-pressed', String(isActive)); + }); + + let visibleCount = 0; + groups.forEach(function(group) { + const cards = Array.from(group.querySelectorAll('[data-comment-card]')); + let groupVisibleCount = 0; + cards.forEach(function(card) { + const visible = cardMatches(card); + card.hidden = !visible; + if (visible) { + groupVisibleCount++; + visibleCount++; + } + }); + group.hidden = groupVisibleCount === 0; + const count = group.querySelector('[data-comment-count]'); + if (count) { + count.textContent = groupVisibleCount + ' comment' + (groupVisibleCount === 1 ? '' : 's'); + } + }); + + if (emptyState) { + emptyState.hidden = visibleCount !== 0; + } + } + + filters.forEach(function(filter) { + filter.addEventListener('click', function() { + const kind = filter.dataset.filterKind; + const value = filter.dataset.filterValue || ''; + if (kind === 'all' || (activeKind === kind && activeValue === value)) { + activeKind = 'all'; + activeValue = ''; + } else { + activeKind = kind; + activeValue = value; + } + updateFilterState(); + }); + }); + + updateFilterState(); +})(); diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index 1fab6efc..9bae9bb7 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -885,6 +885,40 @@ p { gap: 0.5rem; margin-bottom: 1.25rem; } +.comment-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.65rem; +} +.category-filters { + margin-bottom: 1.25rem; +} +.comment-filter-chip { + appearance: none; + border: 1px solid transparent; + border-radius: 20px; + padding: 0.3em 0.75em; + font: inherit; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; + cursor: pointer; + transition: opacity var(--transition), box-shadow var(--transition), border-color var(--transition); +} +.comment-filter-chip:not(.is-active) { opacity: 0.72; } +.comment-filter-chip:hover, +.comment-filter-chip.is-active { opacity: 1; } +.comment-filter-chip.is-active { + border-color: currentColor; + box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px currentColor; +} +.comment-filter-chip:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} +.filter-all { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .severity-badge { padding: 0.3em 0.75em; border-radius: 20px; @@ -970,6 +1004,11 @@ p { .cat-bug { background: #fef2f2; color: #dc2626; } .cat-security { background: #fdf2f8; color: #be185d; } .cat-performance { background: #fff7ed; color: #ea580c; } +.cat-maintainability { background: #eff6ff; color: #2563eb; } +.cat-test { background: #f0fdf4; color: #15803d; } +.cat-style { background: #f5f3ff; color: #7c3aed; } +.cat-documentation { background: #ecfeff; color: #0e7490; } +.cat-other { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .cat-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .severity-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } @@ -977,9 +1016,21 @@ p { .cat-bug { background: rgba(220, 38, 38, 0.12); color: #fca5a5; } .cat-security { background: rgba(190, 24, 93, 0.12); color: #f9a8d4; } .cat-performance { background: rgba(234, 88, 12, 0.12); color: #fdba74; } + .cat-maintainability { background: rgba(37, 99, 235, 0.12); color: #93c5fd; } + .cat-test { background: rgba(21, 128, 61, 0.12); color: #86efac; } + .cat-style { background: rgba(124, 58, 237, 0.12); color: #c4b5fd; } + .cat-documentation { background: rgba(14, 116, 144, 0.12); color: #67e8f9; } + .cat-other { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .cat-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } } +.comment-filter-empty { + margin: 1.25rem 0 0; + color: var(--text-muted); + text-align: center; + font-size: 0.85rem; +} + .comment-lines { font-family: var(--mono); font-size: 0.72rem; diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index a6adfbce..52a75273 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -110,11 +110,24 @@
Review Comments ({{len .Session.Comments}} findings)
{{with severityCounts .Session.Comments}} -Review Comments ({{len .Session.Comments}} findings)
Review Comments ({{len .Session.Comments}} findings)
{{end}}No comments match this filter.