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 @@

What sh } const MINI_SUMMARY_HISTORY_PREFIX = 'hive_visual_summary_history:'; const MINI_SUMMARY_HISTORY_POINTS = 48; + const REVIEW_QUEUE_WINDOW_KEY = 'hive_review_queue_history_window'; + const REVIEW_QUEUE_MS_PER_HOUR = 3600e3; + const REVIEW_QUEUE_HOURS_PER_DAY = 24; + const REVIEW_QUEUE_DAYS_PER_WEEK = 7; + const REVIEW_QUEUE_WINDOWS = { '24h': REVIEW_QUEUE_HOURS_PER_DAY * REVIEW_QUEUE_MS_PER_HOUR, '7d': REVIEW_QUEUE_DAYS_PER_WEEK * REVIEW_QUEUE_HOURS_PER_DAY * REVIEW_QUEUE_MS_PER_HOUR }; + const REVIEW_QUEUE_SPARK_W = 128; + const REVIEW_QUEUE_SPARK_H = 24; + const REVIEW_QUEUE_SPARK_PAD_Y = 2; + let _reviewQueueWindow = (() => { + try { + const saved = localStorage.getItem(REVIEW_QUEUE_WINDOW_KEY); + return Object.prototype.hasOwnProperty.call(REVIEW_QUEUE_WINDOWS, saved) ? saved : '24h'; + } catch { return '24h'; } + })(); function miniHiveKey() { return String(((window._lastStatus || {}).hiveId) || 'default').trim() || 'default'; } @@ -5501,6 +5533,86 @@

What sh const title = label || text; return `${miniTitleTag(title)}${escapeHtml(text.slice(0, 10))}`; } + function reviewQueueHistoryEntries() { + const cutoff = Date.now() - (REVIEW_QUEUE_WINDOWS[_reviewQueueWindow] || REVIEW_QUEUE_WINDOWS['24h']); + const rows = Array.isArray(window._reviewQueueHistory) ? window._reviewQueueHistory : []; + return rows.filter(row => { + const t = Number(row && row.t); + return Number.isFinite(t) && t >= cutoff; + }); + } + function reviewQueueSeries(entries, key) { + return (entries || []).map(row => Number(row && row[key]) || 0); + } + function reviewQueueSparkPoints(values, min, max) { + const series = (Array.isArray(values) ? values : []).map(v => Number(v) || 0); + const range = (max - min) || max || 1; + const plotH = REVIEW_QUEUE_SPARK_H - REVIEW_QUEUE_SPARK_PAD_Y * 2; + if (series.length <= 1) { + const y = REVIEW_QUEUE_SPARK_PAD_Y + plotH - (((series[0] || 0) - min) / range) * plotH; + return `0,${y.toFixed(1)} ${REVIEW_QUEUE_SPARK_W.toFixed(1)},${y.toFixed(1)}`; + } + return series.map((v, i) => { + const x = (i / (series.length - 1)) * REVIEW_QUEUE_SPARK_W; + const y = REVIEW_QUEUE_SPARK_PAD_Y + plotH - ((v - min) / range) * plotH; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + } + function reviewQueueRangeControls() { + return `${Object.keys(REVIEW_QUEUE_WINDOWS).map(win => ``).join('')}`; + } + function reviewQueueSparkline(entries) { + const total = reviewQueueSeries(entries, 'total'); + const high = reviewQueueSeries(entries, 'high'); + const normal = reviewQueueSeries(entries, 'normal'); + const low = reviewQueueSeries(entries, 'low'); + const values = total.length ? total : [0, 0]; + const max = Math.max(1, ...values, ...high, ...normal, ...low); + const latest = values[values.length - 1] || 0; + const min = Math.min(0, ...values); + const title = `min ${fmtSparkVal(min)} · max ${fmtSparkVal(max)} · latest ${fmtSparkVal(latest)}`; + const after = [ + ['high', high, 'High priority'], + ['normal', normal, 'Normal priority'], + ['low', low, 'Low priority'], + ].map(([key, series, label]) => series.length ? `${escapeHtml(label)}: latest ${fmtSparkVal(series[series.length - 1] || 0)}` : '').join(''); + return `${renderSparkline(null, values, { + width: REVIEW_QUEUE_SPARK_W, height: REVIEW_QUEUE_SPARK_H, min, max, color: 'color-mix(in srgb, var(--purple) 60%, transparent)', + title, ariaLabel: `Review Queue total: ${title}`, label: 'review queue total', id: 'review-queue-collapsed', + dot: false, strokeWidth: 1, after, svgClass: 'mini-spark', wrapperClass: 'sparkline--flush', wrapperTitle: title, + })}`; + } + function reviewQueueCollapsedSummary(text) { + const entries = reviewQueueHistoryEntries(); + const latest = entries[entries.length - 1] || {}; + const total = Number(latest.total); + const shownTotal = Number.isFinite(total) ? total : miniFirstNumber(text); + const hasPrioritySplit = Number.isFinite(Number(latest.high)) || Number.isFinite(Number(latest.normal)) || Number.isFinite(Number(latest.low)); + const high = Number(latest.high) || 0; + const normal = hasPrioritySplit ? (Number(latest.normal) || 0) : shownTotal; + const low = Number(latest.low) || 0; + const first = entries[0] || {}; + const startTotal = Number(first.total); + const delta = Number.isFinite(startTotal) ? shownTotal - startTotal : 0; + const deltaCls = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'; + const deltaText = delta > 0 ? `▲${delta}` : delta < 0 ? `▼${Math.abs(delta)}` : '•0'; + const chips = [ + ['high', 'H', high], + ['normal', 'N', normal], + ['low', 'L', low], + ].map(([key, label, value]) => `${escapeHtml(label)} ${fmtSparkVal(value)}`).join(''); + const label = `${fmtSparkVal(shownTotal)} PRs · high ${high}, normal ${normal}, low ${low}, ${deltaText} vs range start`; + const sparkEntries = entries.length ? entries : [{ t: Date.now(), total: shownTotal, high, normal, low }]; + const inner = `${reviewQueueRangeControls()}${fmtSparkVal(shownTotal)}${chips}${deltaText}${reviewQueueSparkline(sparkEntries)}`; + return `${inner}`; + } + function setReviewQueueWindow(win) { + if (!Object.prototype.hasOwnProperty.call(REVIEW_QUEUE_WINDOWS, win)) return; + _reviewQueueWindow = win; + try { localStorage.setItem(REVIEW_QUEUE_WINDOW_KEY, win); } catch {} + setSectionSummary('review-queue-section', _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : '0 PRs'); + } + window.setReviewQueueWindow = setReviewQueueWindow; function miniAvatarDots(contributors, total, label) { const list = Array.isArray(contributors) ? contributors.slice(0, 5) : []; const dots = list.map((c, i) => `${escapeHtml((c && (c.github_username || c.login || c.name)) || 'contributor')}`).join(''); @@ -5551,7 +5663,7 @@

What sh } else if (sectionId === 'acmm-eval-section' || sectionId === 'acmm-reco-section') { inner = miniDial(n0, ['L0','L1','L2','L3','L4','L5','L6'], text); } else if (sectionId === 'review-queue-section') { - inner = miniGauge(n0, Math.max(10, n0), 'var(--purple)', text); + return reviewQueueCollapsedSummary(text); } else if (sectionId === 'inception-section') { inner = miniBar([{ label: 'proposals', value: n0, color: 'var(--blue)' }, { label: 'in progress', value: nums[1] || 0, color: 'var(--green)' }], text); } else if (sectionId === 'contributors-section') { @@ -20125,6 +20237,7 @@

var _reviewQueueLimit = REVIEW_QUEUE_PAGE_SIZE; var _reviewQueueTotal = 0; var _reviewQueueHasMore = false; + window._reviewQueueHistory = window._reviewQueueHistory || []; // reviewQueuePriorityMeta maps the API's priority (high|normal|low) to the // review-priority/* label family the backend mirrors onto PRs; the badge @@ -20183,6 +20296,7 @@ _reviewQueueLimit = Number(data && data.limit) || REVIEW_QUEUE_PAGE_SIZE; _reviewQueueOffset = Number(data && data.offset) || 0; _reviewQueueHasMore = !!(data && data.has_more); + window._reviewQueueHistory = capDashboardArray((data && Array.isArray(data.history)) ? data.history : window._reviewQueueHistory, DASHBOARD_SERVER_HISTORY_CAP); var countEl = document.getElementById('review-queue-count'); if (countEl) countEl.textContent = _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : ''; setSectionSummary('review-queue-section', _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : '0 PRs');