diff --git a/changelog.d/added-10613.md b/changelog.d/added-10613.md new file mode 100644 index 0000000000..fc7f5aadcf --- /dev/null +++ b/changelog.d/added-10613.md @@ -0,0 +1 @@ +- dashboard: Review Queue collapsed row shows a priority-split history sparkline (#10613) diff --git a/changelog.d/fixed-10613-review-queue-summary.md b/changelog.d/fixed-10613-review-queue-summary.md new file mode 100644 index 0000000000..e5e7b8e25a --- /dev/null +++ b/changelog.d/fixed-10613-review-queue-summary.md @@ -0,0 +1 @@ +- Fixed the review queue collapsed summary escape helper used by visual summary tests. diff --git a/src/changelog.d/changed-review-queue-collapsed-sparkline-10610.md b/src/changelog.d/changed-review-queue-collapsed-sparkline-10610.md new file mode 100644 index 0000000000..e46296ee92 --- /dev/null +++ b/src/changelog.d/changed-review-queue-collapsed-sparkline-10610.md @@ -0,0 +1 @@ +- Changed the Review Queue collapsed row to show priority-split history with shared sparkline range controls (#10610) diff --git a/src/pkg/dashboard/api_review_queue.go b/src/pkg/dashboard/api_review_queue.go index d96f347b23..b1cd384e29 100644 --- a/src/pkg/dashboard/api_review_queue.go +++ b/src/pkg/dashboard/api_review_queue.go @@ -6,6 +6,7 @@ import ( "os" "strconv" "strings" + "sync" "time" ghpkg "github.com/hivecommons/hive/pkg/github" @@ -20,15 +21,32 @@ const ( reviewQueueMaxLimit = 200 ) +type reviewQueueHistoryStore struct { + mu sync.Mutex + entries []ReviewQueueHistoryEntry +} + +var reviewQueueHistoryByServer sync.Map + +// ReviewQueueHistoryEntry is one priority-split review queue depth sample. +type ReviewQueueHistoryEntry struct { + Timestamp int64 `json:"t"` + Total int `json:"total"` + High int `json:"high"` + Normal int `json:"normal"` + Low int `json:"low"` +} + // reviewQueueResponse is one page of the PR review queue. type reviewQueueResponse struct { - GeneratedAt time.Time `json:"generated_at"` - SnapshotAt time.Time `json:"snapshot_at,omitzero"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - HasMore bool `json:"has_more"` - Items []ghpkg.ReviewQueueEntry `json:"items"` + GeneratedAt time.Time `json:"generated_at"` + SnapshotAt time.Time `json:"snapshot_at,omitzero"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasMore bool `json:"has_more"` + History []ReviewQueueHistoryEntry `json:"history"` + Items []ghpkg.ReviewQueueEntry `json:"items"` } // handleReviewQueue serves GET /api/review/queue?limit=N&offset=M: every open @@ -65,6 +83,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { } actionable := s.lastActionableForPRModels() queue := ghpkg.ReviewQueueFromActionable(actionable, opts) + history := s.recordReviewQueueHistory(queue, opts.Now) resp := reviewQueueResponse{ GeneratedAt: opts.Now.UTC(), @@ -72,6 +91,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { Total: len(queue), Limit: limit, Offset: offset, + History: history, Items: []ghpkg.ReviewQueueEntry{}, } if offset < len(queue) { @@ -109,3 +129,36 @@ func reviewQueuePaging(r *http.Request) (limit, offset int, err error) { } return limit, offset, nil } + +func (s *Server) recordReviewQueueHistory(queue []ghpkg.ReviewQueueEntry, now time.Time) []ReviewQueueHistoryEntry { + if s == nil { + return nil + } + entry := ReviewQueueHistoryEntry{Timestamp: now.UTC().UnixMilli(), Total: len(queue)} + for _, item := range queue { + switch item.Priority { + case ghpkg.ReviewPriorityHigh: + entry.High++ + case ghpkg.ReviewPriorityLow: + entry.Low++ + default: + entry.Normal++ + } + } + + storeAny, _ := reviewQueueHistoryByServer.LoadOrStore(s, &reviewQueueHistoryStore{}) + store := storeAny.(*reviewQueueHistoryStore) + store.mu.Lock() + defer store.mu.Unlock() + if n := len(store.entries); n > 0 && entry.Timestamp-store.entries[n-1].Timestamp < trendHistoryMinIntervalMs { + store.entries[n-1] = entry + } else { + store.entries = append(store.entries, entry) + } + if len(store.entries) > trendHistoryMaxEntries { + store.entries = store.entries[len(store.entries)-trendHistoryMaxEntries:] + } + out := make([]ReviewQueueHistoryEntry, len(store.entries)) + copy(out, store.entries) + return out +} diff --git a/src/pkg/dashboard/api_review_queue_test.go b/src/pkg/dashboard/api_review_queue_test.go index c5738f7f45..a94a246be7 100644 --- a/src/pkg/dashboard/api_review_queue_test.go +++ b/src/pkg/dashboard/api_review_queue_test.go @@ -94,6 +94,21 @@ func TestHandleReviewQueue_RanksAndPages(t *testing.T) { } } +func TestHandleReviewQueue_IncludesPriorityHistory(t *testing.T) { + s := reviewQueueTestServer(t, filepath.Join(t.TempDir(), "missing.json")) + resp := decodeReviewQueue(t, doOwnerGet(s, "/api/review/queue")) + if len(resp.History) != 1 { + t.Fatalf("history len = %d, want 1", len(resp.History)) + } + entry := resp.History[0] + if entry.Total != 3 || entry.High != 1 || entry.Normal != 1 || entry.Low != 1 { + t.Fatalf("history priority split = %+v, want total/high/normal/low 3/1/1/1", entry) + } + if entry.Timestamp <= 0 { + t.Fatalf("history timestamp = %d, want unix millis", entry.Timestamp) + } +} + func TestHandleReviewQueue_RejectsBadPaging(t *testing.T) { s := reviewQueueTestServer(t, filepath.Join(t.TempDir(), "missing.json")) for _, q := range []string{"limit=0", "limit=-1", "limit=abc", "limit=201", "offset=-1", "offset=x"} { diff --git a/src/pkg/dashboard/review_queue_view_9607_test.go b/src/pkg/dashboard/review_queue_view_9607_test.go index 772b93b632..b9fafdc261 100644 --- a/src/pkg/dashboard/review_queue_view_9607_test.go +++ b/src/pkg/dashboard/review_queue_view_9607_test.go @@ -26,6 +26,25 @@ func TestReviewQueueViewStaticWiring(t *testing.T) { t.Errorf("index.html is missing review-queue wiring %q", want) } } + // The collapsed Review Queue card must use the shared range-control and + // renderSparkline path, not the old count-only gauge. + for _, want := range []string{ + `function reviewQueueCollapsedSummary(text)`, + `function reviewQueueRangeControls()`, + `overview-kpi-range-btn`, + `data-action="setReviewQueueWindow"`, + `function reviewQueueSparkline(entries)`, + `renderSparkline(null, values`, + `['high', high, 'High priority']`, + } { + if !strings.Contains(html, want) { + t.Errorf("index.html is missing review-queue collapsed sparkline wiring %q", want) + } + } + if strings.Contains(html, `sectionId === 'review-queue-section') { + inner = miniGauge`) { + t.Error("Review Queue collapsed summary still uses the gauge renderer") + } // The view must never fall back to a browser-native dialog. for _, banned := range []string{"window.alert", "window.confirm", "window.prompt"} { if strings.Contains(jsFunc(t, html, "renderReviewQueue"), banned) { diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 5bb8055b2a..2cc1f37fbd 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -3819,6 +3819,24 @@ .review-queue-reasons { margin: var(--sp-2) 0 0; padding-left: var(--sp-6); color: var(--muted); font-size: var(--fs-xs); } .review-queue-noreasons { color: var(--muted); font-size: var(--fs-xs); margin-top: var(--sp-2); } .review-queue-empty { color: var(--terminal-muted); font-size: var(--fs-base); padding: var(--sp-4); } + .review-queue-collapsed { width: min(460px, 44vw); max-width: 460px; gap: var(--sp-3); } + .review-queue-collapsed-main { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; } + .review-queue-collapsed-total { color: var(--text); font-weight: 900; font-variant-numeric: tabular-nums; } + .review-queue-chip { display: inline-flex; align-items: center; gap: var(--sp-1); padding: var(--sp-0) var(--sp-2); border: 1px solid var(--blue); border-radius: var(--r-pill); color: var(--muted); font-size: var(--fs-2xs); font-weight: 800; font-variant-numeric: tabular-nums; } + .review-queue-chip.high { border-color: var(--red); } + .review-queue-chip.low { border-color: var(--muted); } + .review-queue-delta { font-size: var(--fs-2xs); font-weight: 900; font-variant-numeric: tabular-nums; } + .review-queue-delta.up { color: var(--red); } + .review-queue-delta.down { color: var(--green); } + .review-queue-delta.flat { color: var(--muted); } + .review-queue-spark { flex: 1 1 120px; min-width: 96px; } + .review-queue-spark .sparkline { width: 100%; } + .review-queue-spark svg { width: 100%; height: 24px; } + .review-queue-priority-line { fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: 0.9; } + .review-queue-priority-line.high { stroke: var(--red); } + .review-queue-priority-line.normal { stroke: var(--blue); } + .review-queue-priority-line.low { stroke: var(--muted); } + .review-queue-collapsed-range { position: static; flex: 0 0 auto; } .review-priority { display: inline-block; padding: var(--sp-1) var(--sp-4); border-radius: var(--r-sm); font-size: var(--fs-2xs); color: var(--muted); border: 1px solid var(--blue); } .review-priority-high { border-color: var(--red); } .review-priority-low { border-color: var(--muted); } @@ -5407,6 +5425,20 @@