@@ -139,6 +139,7 @@ class Dashboard {
139139 <div id="dashboard-advice-summary" class="dashboard-summary-body">Loading…</div>
140140 <div class="dashboard-summary-actions">
141141 <button class="dashboard-topbar-btn" id="dashboard-open-queue" title="Open Queue">📥 Queue</button>
142+ <button class="dashboard-topbar-btn" id="dashboard-open-queue-viz" title="Work queue visualization">🧭 Viz</button>
142143 <button class="dashboard-topbar-btn" id="dashboard-open-advice" title="Open Commander Advice">🧠 Advice</button>
143144 <button class="dashboard-topbar-btn" id="dashboard-open-suggestions" title="Open workspace suggestions">✨ Suggestions</button>
144145 <button class="dashboard-topbar-btn" id="dashboard-open-distribution" title="Suggested terminal per PR/task">🎯 Distribution</button>
@@ -224,6 +225,10 @@ class Dashboard {
224225 e . preventDefault ( ) ;
225226 this . orchestrator ?. showQueuePanel ?. ( ) . catch ?. ( ( ) => { } ) ;
226227 } ) ;
228+ document . getElementById ( 'dashboard-open-queue-viz' ) ?. addEventListener ( 'click' , ( e ) => {
229+ e . preventDefault ( ) ;
230+ this . showQueueVizOverlay ( ) . catch ( ( ) => { } ) ;
231+ } ) ;
227232 document . getElementById ( 'dashboard-open-prs' ) ?. addEventListener ( 'click' , ( e ) => {
228233 e . preventDefault ( ) ;
229234 try {
@@ -638,6 +643,187 @@ class Dashboard {
638643 await this . loadPerformanceDetails ( ) ;
639644 }
640645
646+ async showQueueVizOverlay ( ) {
647+ const existing = document . getElementById ( 'dashboard-queue-viz-overlay' ) ;
648+ if ( existing ) {
649+ existing . classList . remove ( 'hidden' ) ;
650+ return ;
651+ }
652+
653+ const overlay = document . createElement ( 'div' ) ;
654+ overlay . id = 'dashboard-queue-viz-overlay' ;
655+ overlay . className = 'dashboard-telemetry-overlay' ;
656+ overlay . innerHTML = `
657+ <div class="dashboard-telemetry-panel" role="dialog" aria-label="Work queue visualization">
658+ <div class="dashboard-telemetry-header">
659+ <div class="dashboard-telemetry-title">Queue — Visualization</div>
660+ <button class="dashboard-topbar-btn" id="dashboard-queue-viz-close" title="Close (Esc)">✕</button>
661+ </div>
662+ <div class="dashboard-telemetry-controls">
663+ <div class="dashboard-telemetry-actions">
664+ <button class="btn-secondary" type="button" id="dashboard-queue-viz-open-queue">📥 Open Queue</button>
665+ <button class="btn-secondary" type="button" id="dashboard-queue-viz-refresh">Refresh</button>
666+ </div>
667+ </div>
668+ <div id="dashboard-queue-viz-body" class="dashboard-telemetry-body">Loading…</div>
669+ </div>
670+ ` ;
671+
672+ document . body . appendChild ( overlay ) ;
673+
674+ const close = ( ) => this . hideQueueVizOverlay ( ) ;
675+ overlay . addEventListener ( 'click' , ( e ) => {
676+ if ( e . target === overlay ) close ( ) ;
677+ } ) ;
678+ overlay . querySelector ( '#dashboard-queue-viz-close' ) ?. addEventListener ( 'click' , close ) ;
679+ overlay . querySelector ( '#dashboard-queue-viz-open-queue' ) ?. addEventListener ( 'click' , ( ) => {
680+ close ( ) ;
681+ this . orchestrator ?. showQueuePanel ?. ( ) . catch ?. ( ( ) => { } ) ;
682+ } ) ;
683+ overlay . querySelector ( '#dashboard-queue-viz-refresh' ) ?. addEventListener ( 'click' , ( ) => {
684+ this . loadQueueVizDetails ( ) . catch ( ( ) => { } ) ;
685+ } ) ;
686+
687+ const onKey = ( e ) => {
688+ if ( e . key !== 'Escape' ) return ;
689+ const el = document . getElementById ( 'dashboard-queue-viz-overlay' ) ;
690+ if ( ! el || el . classList . contains ( 'hidden' ) ) return ;
691+ close ( ) ;
692+ } ;
693+ overlay . _escHandler = onKey ;
694+ document . addEventListener ( 'keydown' , onKey ) ;
695+
696+ await this . loadQueueVizDetails ( ) ;
697+ }
698+
699+ hideQueueVizOverlay ( ) {
700+ const overlay = document . getElementById ( 'dashboard-queue-viz-overlay' ) ;
701+ if ( ! overlay ) return ;
702+ overlay . classList . add ( 'hidden' ) ;
703+ const handler = overlay . _escHandler ;
704+ if ( handler ) {
705+ document . removeEventListener ( 'keydown' , handler ) ;
706+ overlay . _escHandler = null ;
707+ }
708+ overlay . remove ( ) ;
709+ }
710+
711+ async loadQueueVizDetails ( ) {
712+ const bodyEl = document . getElementById ( 'dashboard-queue-viz-body' ) ;
713+ if ( bodyEl ) bodyEl . textContent = 'Loading…' ;
714+
715+ let data = null ;
716+ try {
717+ const url = new URL ( '/api/process/tasks' , window . location . origin ) ;
718+ url . searchParams . set ( 'mode' , 'all' ) ;
719+ url . searchParams . set ( 'state' , 'open' ) ;
720+ url . searchParams . set ( 'include' , 'dependencySummary' ) ;
721+ const res = await fetch ( url . toString ( ) ) ;
722+ data = res && res . ok ? await res . json ( ) . catch ( ( ) => null ) : null ;
723+ } catch {
724+ data = null ;
725+ }
726+
727+ if ( ! bodyEl ) return ;
728+ const tasks = Array . isArray ( data ?. tasks ) ? data . tasks : [ ] ;
729+ if ( ! data || ! tasks ) {
730+ bodyEl . textContent = 'Failed to load.' ;
731+ return ;
732+ }
733+
734+ const escapeHtml = ( value ) => String ( value ?? '' )
735+ . replace ( / & / g, '&' )
736+ . replace ( / < / g, '<' )
737+ . replace ( / > / g, '>' ) ;
738+
739+ const tierKey = ( t ) => {
740+ const tier = t ?. record ?. tier ;
741+ const n = Number ( tier ) ;
742+ return ( Number . isFinite ( n ) && n >= 1 && n <= 4 ) ? `T${ n } ` : 'None' ;
743+ } ;
744+
745+ const counts = { T1 : 0 , T2 : 0 , T3 : 0 , T4 : 0 , None : 0 } ;
746+ const unclaimed = { T1 : 0 , T2 : 0 , T3 : 0 , T4 : 0 , None : 0 } ;
747+ const unassigned = { T1 : 0 , T2 : 0 , T3 : 0 , T4 : 0 , None : 0 } ;
748+ const byAssignee = { } ;
749+
750+ for ( const t of tasks ) {
751+ const k = tierKey ( t ) ;
752+ counts [ k ] = ( counts [ k ] || 0 ) + 1 ;
753+ const claimedBy = String ( t ?. record ?. claimedBy || '' ) . trim ( ) ;
754+ const assignedTo = String ( t ?. record ?. assignedTo || '' ) . trim ( ) ;
755+ if ( ! claimedBy ) unclaimed [ k ] = ( unclaimed [ k ] || 0 ) + 1 ;
756+ if ( ! assignedTo ) unassigned [ k ] = ( unassigned [ k ] || 0 ) + 1 ;
757+
758+ const bucket = assignedTo || '(unassigned)' ;
759+ if ( ! byAssignee [ bucket ] ) byAssignee [ bucket ] = { T1 : 0 , T2 : 0 , T3 : 0 , T4 : 0 , None : 0 , total : 0 } ;
760+ byAssignee [ bucket ] [ k ] = ( byAssignee [ bucket ] [ k ] || 0 ) + 1 ;
761+ byAssignee [ bucket ] . total += 1 ;
762+ }
763+
764+ const assignees = Object . entries ( byAssignee )
765+ . sort ( ( a , b ) => ( b [ 1 ] . total || 0 ) - ( a [ 1 ] . total || 0 ) || String ( a [ 0 ] ) . localeCompare ( String ( b [ 0 ] ) ) ) ;
766+
767+ const rows = assignees . map ( ( [ who , c ] ) => {
768+ return `
769+ <tr>
770+ <td class="mono">${ escapeHtml ( who ) } </td>
771+ <td class="mono">${ escapeHtml ( c . T1 || 0 ) } </td>
772+ <td class="mono">${ escapeHtml ( c . T2 || 0 ) } </td>
773+ <td class="mono">${ escapeHtml ( c . T3 || 0 ) } </td>
774+ <td class="mono">${ escapeHtml ( c . T4 || 0 ) } </td>
775+ <td class="mono">${ escapeHtml ( c . None || 0 ) } </td>
776+ <td class="mono">${ escapeHtml ( c . total || 0 ) } </td>
777+ </tr>
778+ ` ;
779+ } ) . join ( '' ) ;
780+
781+ const sumRow = `
782+ <tr>
783+ <td class="mono"><strong>Total</strong></td>
784+ <td class="mono"><strong>${ escapeHtml ( counts . T1 ) } </strong></td>
785+ <td class="mono"><strong>${ escapeHtml ( counts . T2 ) } </strong></td>
786+ <td class="mono"><strong>${ escapeHtml ( counts . T3 ) } </strong></td>
787+ <td class="mono"><strong>${ escapeHtml ( counts . T4 ) } </strong></td>
788+ <td class="mono"><strong>${ escapeHtml ( counts . None ) } </strong></td>
789+ <td class="mono"><strong>${ escapeHtml ( tasks . length ) } </strong></td>
790+ </tr>
791+ ` ;
792+
793+ bodyEl . innerHTML = `
794+ <div class="dashboard-telemetry-muted">
795+ Items: <strong>${ escapeHtml ( tasks . length ) } </strong> • Unclaimed: <strong>${ escapeHtml ( Object . values ( unclaimed ) . reduce ( ( a , b ) => a + ( b || 0 ) , 0 ) ) } </strong> • Unassigned: <strong>${ escapeHtml ( Object . values ( unassigned ) . reduce ( ( a , b ) => a + ( b || 0 ) , 0 ) ) } </strong>
796+ </div>
797+ <div style="margin-top:10px; display:flex; flex-wrap:wrap; gap:8px;">
798+ <span class="pr-badge" title="Total items per tier">T1 ${ escapeHtml ( counts . T1 ) } </span>
799+ <span class="pr-badge">T2 ${ escapeHtml ( counts . T2 ) } </span>
800+ <span class="pr-badge">T3 ${ escapeHtml ( counts . T3 ) } </span>
801+ <span class="pr-badge">T4 ${ escapeHtml ( counts . T4 ) } </span>
802+ <span class="pr-badge">None ${ escapeHtml ( counts . None ) } </span>
803+ <span class="pr-badge" title="Unclaimed items per tier">Unclaimed T1 ${ escapeHtml ( unclaimed . T1 ) } • T2 ${ escapeHtml ( unclaimed . T2 ) } • T3 ${ escapeHtml ( unclaimed . T3 ) } • T4 ${ escapeHtml ( unclaimed . T4 ) } • None ${ escapeHtml ( unclaimed . None ) } </span>
804+ <span class="pr-badge" title="Unassigned items per tier">Unassigned T1 ${ escapeHtml ( unassigned . T1 ) } • T2 ${ escapeHtml ( unassigned . T2 ) } • T3 ${ escapeHtml ( unassigned . T3 ) } • T4 ${ escapeHtml ( unassigned . T4 ) } • None ${ escapeHtml ( unassigned . None ) } </span>
805+ </div>
806+
807+ <table class="worktree-inspector-table" style="margin-top:12px;">
808+ <thead>
809+ <tr>
810+ <th>Assigned to</th>
811+ <th>T1</th>
812+ <th>T2</th>
813+ <th>T3</th>
814+ <th>T4</th>
815+ <th>None</th>
816+ <th>Total</th>
817+ </tr>
818+ </thead>
819+ <tbody>
820+ ${ rows || `<tr><td colspan="7" style="opacity:0.8;">No items.</td></tr>` }
821+ ${ sumRow }
822+ </tbody>
823+ </table>
824+ ` ;
825+ }
826+
641827 hidePerformanceOverlay ( ) {
642828 const overlay = document . getElementById ( 'dashboard-performance-overlay' ) ;
643829 if ( ! overlay ) return ;
0 commit comments