← back to Ticket Action Viewer
Viewer: make all data columns sortable (clickable headers + sort select, numeric ticket/priority, persisted) + Table⇄List view toggle
c1736a2ba4ad6f0d4e5969f5795596e5ae6940d5 · 2026-08-11 12:38:07 -0700 · steve
Files touched
Diff
commit c1736a2ba4ad6f0d4e5969f5795596e5ae6940d5
Author: steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 12:38:07 2026 -0700
Viewer: make all data columns sortable (clickable headers + sort select, numeric ticket/priority, persisted) + Table⇄List view toggle
---
public/index.html | 150 ++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 135 insertions(+), 15 deletions(-)
diff --git a/public/index.html b/public/index.html
index de0358b..d40e524 100644
--- a/public/index.html
+++ b/public/index.html
@@ -47,6 +47,29 @@
#modal pre{white-space:pre-wrap;font:12px/1.55 ui-monospace,Menlo,monospace;color:#cfd6e2}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#222834;border:1px solid var(--line);border-radius:9px;padding:9px 16px;display:none;z-index:30}
.x{float:right;cursor:pointer;color:var(--mut);font-size:20px;line-height:1}
+ /* sortable column headers */
+ th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
+ th.sortable:hover{color:var(--ink)}
+ th.sortable.active{color:var(--acc)}
+ th .ar{display:inline-block;width:12px;color:var(--acc);font-size:10px}
+ /* segmented view toggle (Table / List) */
+ .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+ .seg button{background:var(--card);border:0;color:var(--mut);padding:7px 12px;font:600 12px/1 inherit;cursor:pointer}
+ .seg button.on{background:var(--acc);color:#fff}
+ /* list view */
+ #listv{display:none;flex-direction:column;gap:8px}
+ body.view-list #tablev{display:none}
+ body.view-list #listv{display:flex}
+ .li{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:11px 13px}
+ .li:hover{border-color:#33404f}
+ .li-top{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
+ .li .rank{min-width:26px}
+ .li .ttl{flex:1;min-width:180px}
+ .li-meta{color:var(--mut);font-size:12px;margin-top:5px}
+ .li-meta .sep{opacity:.4;margin:0 6px}
+ .li-next{color:#cfd6e2;font-size:12px;margin-top:5px}
+ .li .needs{margin-top:6px}
+ .li .acts{margin-top:9px}
</style></head><body>
<header>
<h1>🎫 Ticket Action Viewer <small id="sub">loading…</small></h1>
@@ -55,20 +78,88 @@
<select id="fstatus"><option value="">All statuses</option><option>open</option><option>doing</option><option>blocked</option></select>
<select id="fproject"><option value="">All projects</option></select>
<label class="tog"><input type="checkbox" id="fgated"> Gated only (needs your input)</label>
+ <span class="seg" id="viewseg">
+ <button id="vTable" onclick="setView('table')">▤ Table</button>
+ <button id="vList" onclick="setView('list')">☰ List</button>
+ </span>
+ <label class="tog">Sort
+ <select id="fsort"></select>
+ <button class="k" id="fdir" onclick="toggleDir()" title="Toggle ascending / descending">▼</button>
+ </label>
<span class="chip">blocked <b id="cB">0</b></span><span class="chip">doing <b id="cD">0</b></span><span class="chip">open <b id="cO">0</b></span>
<button class="k" onclick="load()">↻ Refresh</button>
<a class="k" href="http://127.0.0.1:9794" target="_blank" style="text-decoration:none">Board ↗</a>
</div>
</header>
-<main><table><thead><tr>
- <th>#</th><th>Ticket</th><th>Project</th><th>Status</th><th>Owner</th><th>Next Step</th><th>Gated · what's needed to start</th><th>Actions</th>
-</tr></thead><tbody id="rows"></tbody></table></main>
+<main>
+ <table id="tablev"><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table>
+ <div id="listv"></div>
+</main>
<div id="modal"><div class="box"><span class="x" onclick="closeM()">×</span><h3 id="mtitle"></h3><pre id="mbody"></pre></div></div>
<div id="toast"></div>
<script>
const STALE_MS=864e5; // 1 day in ms — timer turns red above this threshold
let ALL=[];
const el=id=>document.getElementById(id);
+// Column config — one declaration drives the clickable headers, the sort <select>,
+// and the comparator. key=null means "not sortable" (the Actions column has no data).
+const COLS=[
+ {key:'rank', label:'#'},
+ {key:'ticket', label:'Ticket'},
+ {key:'project',label:'Project'},
+ {key:'status', label:'Status'},
+ {key:'owner', label:'Owner'},
+ {key:'next', label:'Next Step'},
+ {key:'gated', label:"Gated · what's needed to start"},
+ {key:null, label:'Actions'},
+];
+// Sort + view state, persisted so the choice survives reloads (Steve rule).
+let SORT=(()=>{try{return JSON.parse(localStorage.getItem('tav_sort'))||{key:'rank',dir:'desc'};}catch(e){return{key:'rank',dir:'desc'};}})();
+let VIEW=localStorage.getItem('tav_view')||'table';
+// Comparable value per column. Numbers sort numerically (priority, ticket #, status weight,
+// gated boolean); strings compare with localeCompare. Status uses a workflow weight so
+// "doing now" surfaces above open above blocked when sorted ascending.
+const TKNUM=t=>parseInt((String(t.shortId).match(/\d+/)||[0])[0],10);
+function sortVal(t,key){switch(key){
+ case 'rank': return Number(t.priority||0);
+ case 'ticket': return TKNUM(t);
+ case 'status': return ({doing:0,open:1,blocked:2}[t.status]??9);
+ case 'gated': return t.gated?0:1;
+ case 'project': return String(t.project||'').toLowerCase();
+ case 'owner': return String(t.owner||'').toLowerCase();
+ case 'next': return String(t.nextStep||'').toLowerCase();
+ default: return '';
+}}
+function sortList(list){
+ const {key,dir}=SORT,mul=dir==='desc'?-1:1;
+ return [...list].sort((a,b)=>{
+ const x=sortVal(a,key),y=sortVal(b,key);
+ let c=(typeof x==='number'&&typeof y==='number')?x-y:String(x).localeCompare(String(y));
+ if(c===0)c=TKNUM(a)-TKNUM(b); // stable tiebreak on ticket number
+ return c*mul;
+ });
+}
+function saveSort(){try{localStorage.setItem('tav_sort',JSON.stringify(SORT));}catch(e){}}
+function syncSortUI(){el('fsort').value=SORT.key;el('fdir').textContent=SORT.dir==='desc'?'▼':'▲';}
+function setSort(key){
+ if(SORT.key===key)SORT.dir=SORT.dir==='asc'?'desc':'asc'; // re-click same column → flip direction
+ else SORT={key,dir:key==='rank'?'desc':'asc'}; // new column → sensible default (rank high-first)
+ saveSort();syncSortUI();render();
+}
+function toggleDir(){SORT.dir=SORT.dir==='asc'?'desc':'asc';saveSort();syncSortUI();render();}
+function setView(v){
+ VIEW=v;try{localStorage.setItem('tav_view',v);}catch(e){}
+ document.body.classList.toggle('view-list',v==='list');
+ el('vTable').classList.toggle('on',v==='table');el('vList').classList.toggle('on',v==='list');
+ render();
+}
+function renderHead(){
+ el('head').innerHTML=COLS.map(c=>{
+ if(!c.key)return `<th>${c.label}</th>`;
+ const active=SORT.key===c.key,ar=active?(SORT.dir==='desc'?'▼':'▲'):'';
+ return `<th class="sortable${active?' active':''}" onclick="setSort('${c.key}')" title="Sort by ${c.label.replace(/·.*/,'').trim()}"><span class="ar">${ar}</span>${c.label}</th>`;
+ }).join('');
+}
async function load(){
const r=await fetch('/api/tickets'); const d=await r.json(); ALL=d.tickets;
el('sub').textContent=`${ALL.length} tickets · agent ${d.agent} · ranked by priority`;
@@ -77,27 +168,51 @@ async function load(){
render();
}
function counts(list){el('cB').textContent=list.filter(t=>t.status==='blocked').length;el('cD').textContent=list.filter(t=>t.status==='doing').length;el('cO').textContent=list.filter(t=>t.status==='open').length;}
-function render(){
+// Filter → sort in one place so table AND list views render the exact same ordered set.
+function currentList(){
const q=el('q').value.toLowerCase(),fs=el('fstatus').value,fp=el('fproject').value,fg=el('fgated').checked;
const list=ALL.filter(t=>(!fs||t.status===fs)&&(!fp||t.project===fp)&&(!fg||t.gated)&&
(!q||(t.shortId+t.title+t.owner+t.project).toLowerCase().includes(q)));
- counts(list);
- el('rows').innerHTML=list.map((t,i)=>`<tr>
- <td class="rank">${i+1}</td>
- <td><div class="tid">${t.shortId}</div><div class="ttl">${esc(t.title)}</div></td>
- <td class="proj">${esc(t.project)}</td>
- <td><span class="st ${t.status}">${t.status==='doing'?'doing now':t.status}</span>${t.status==='doing'&&t.startedAt?`<div class="timer${(Date.now()-Date.parse(t.startedAt))>STALE_MS?' stale':''}" data-start="${esc(t.startedAt)}"><span class="tdot"></span><span class="tval">⏱ …</span><span class="staleflag" title="Running over 1 day — review to close">⚠ >1d</span></div><div class="startedat" title="${esc(t.startedAt)}">▶ started ${fmtStart(t.startedAt)}</div>`:''}</td>
- <td class="owner">${esc(t.owner)}</td>
- <td class="next">${esc(t.nextStep)}${t.note?`<div class="proj" style="margin-top:4px">↳ ${esc(t.note)}</div>`:''}</td>
- <td><div class="needs ${t.gated?'gated':''}">${t.gated?'🔒 ':''}${esc(t.needs||'—')}</div></td>
- <td><div class="acts">
+ return sortList(list);
+}
+// Shared cell fragments so both views stay identical + DRY.
+function statusCell(t){
+ return `<span class="st ${t.status}">${t.status==='doing'?'doing now':t.status}</span>`+
+ (t.status==='doing'&&t.startedAt?`<div class="timer${(Date.now()-Date.parse(t.startedAt))>STALE_MS?' stale':''}" data-start="${esc(t.startedAt)}"><span class="tdot"></span><span class="tval">⏱ …</span><span class="staleflag" title="Running over 1 day — review to close">⚠ >1d</span></div><div class="startedat" title="${esc(t.startedAt)}">▶ started ${fmtStart(t.startedAt)}</div>`:'');
+}
+function actsHtml(t){
+ return `<div class="acts">
<button class="k run" onclick="runNow('${t.fullId}','${esc(t.title).replace(/'/g,'’')}','${esc(t.project)}')">▶ RUN NOW</button>
<button class="k" onclick="detail('${t.fullId}','${t.shortId}')">Detail</button>
<button class="k take" onclick="act('${t.fullId}','take')">Take</button>
<button class="k" onclick="logIt('${t.fullId}')">+Log</button>
<button class="k done" onclick="act('${t.fullId}','done')">Done</button>
<button class="k block" onclick="act('${t.fullId}','block')">Block</button>
- </div></td></tr>`).join('');
+ </div>`;
+}
+function render(){
+ renderHead();
+ const list=currentList();
+ counts(list);
+ if(VIEW==='list'){
+ el('listv').innerHTML=list.map((t,i)=>`<div class="li">
+ <div class="li-top"><span class="rank">#${i+1}</span><span class="tid">${t.shortId}</span>${statusCell(t)}<span class="ttl">${esc(t.title)}</span></div>
+ <div class="li-meta">${esc(t.project||'—')}<span class="sep">·</span>👤 ${esc(t.owner||'—')}${t.gated?'<span class="sep">·</span>🔒 needs your input':''}</div>
+ <div class="li-next">↳ ${esc(t.nextStep)}${t.note?` — ${esc(t.note)}`:''}</div>
+ ${t.gated?`<div class="needs gated">🔒 ${esc(t.needs||'—')}</div>`:''}
+ ${actsHtml(t)}
+ </div>`).join('')||'<div class="li-meta" style="padding:8px">No tickets match.</div>';
+ }else{
+ el('rows').innerHTML=list.map((t,i)=>`<tr>
+ <td class="rank">${i+1}</td>
+ <td><div class="tid">${t.shortId}</div><div class="ttl">${esc(t.title)}</div></td>
+ <td class="proj">${esc(t.project)}</td>
+ <td>${statusCell(t)}</td>
+ <td class="owner">${esc(t.owner)}</td>
+ <td class="next">${esc(t.nextStep)}${t.note?`<div class="proj" style="margin-top:4px">↳ ${esc(t.note)}</div>`:''}</td>
+ <td><div class="needs ${t.gated?'gated':''}">${t.gated?'🔒 ':''}${esc(t.needs||'—')}</div></td>
+ <td>${actsHtml(t)}</td></tr>`).join('');
+ }
tickTimers(); // paint elapsed immediately so the timer doesn't blank for the first second
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
@@ -125,6 +240,11 @@ async function logIt(id){const text=prompt('Log note for '+id.match(/^TK-\d+/)[0
const d=await r.json();toast(d.ok?'✓ logged':'✗ '+d.out.slice(0,80));}
function toast(m){const t=el('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2200);}
['q','fstatus','fproject','fgated'].forEach(id=>el(id).addEventListener('input',render));
+el('fsort').addEventListener('change',()=>setSort(el('fsort').value));
el('modal').addEventListener('click',e=>{if(e.target.id==='modal')closeM()});
+// Init the sort <select> options (sortable columns only) + restore persisted sort/view.
+el('fsort').innerHTML=COLS.filter(c=>c.key).map(c=>`<option value="${c.key}">${c.label.replace(/·.*/,'').trim()}</option>`).join('');
+syncSortUI();
+setView(VIEW); // applies body class + toggle highlight; also triggers the first render path
load();
</script></body></html>
← 8372673 chore: lint (fix act() /^TK-\\d+/ regex bug) + refactor (LA_
·
back to Ticket Action Viewer
·
nav-agent: universal grid-controls drop-in on internal dashb 76fc7f9 →