← back to Ticket Action Viewer

public/index.html

277 lines

<!doctype html><html lang="en"><head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ticket Action Viewer</title>
<style>
  :root{--bg:#0f1115;--card:#171a21;--line:#262b36;--ink:#e8eaed;--mut:#8b93a1;--acc:#4f8cff;--warn:#f2b23a;--gate:#ff6b5a;--ok:#3ecf8e}
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
  header{position:sticky;top:0;z-index:5;background:#0f1115ee;backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
  h1{margin:0 0 8px;font-size:17px;font-weight:700;letter-spacing:.2px}
  h1 small{color:var(--mut);font-weight:400;font-size:12px;margin-left:8px}
  .bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
  .bar input,.bar select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 10px;font:inherit}
  .bar input[type=search]{min-width:240px}
  .chip{font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--mut)}
  .chip b{color:var(--ink)}
  label.tog{display:flex;gap:6px;align-items:center;color:var(--mut);font-size:12px;cursor:pointer}
  main{padding:14px 18px 60px}
  table{width:100%;border-collapse:collapse;font-size:13px}
  th{position:sticky;top:96px;background:#12151c;text-align:left;color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.6px;padding:8px 10px;border-bottom:1px solid var(--line)}
  td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
  tr:hover td{background:#12151c}
  .rank{font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
  .tid{font-weight:700}.tid a{color:var(--acc);text-decoration:none}
  .ttl{color:var(--ink)}.proj{color:var(--mut);font-size:11px}
  .st{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.5px}
  .st.open{background:#173a2a;color:var(--ok)}.st.doing{background:#3a3417;color:var(--warn)}.st.blocked{background:#3a1e1a;color:var(--gate)}
  .timer{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font:700 12px/1 ui-monospace,Menlo,monospace;color:var(--warn);font-variant-numeric:tabular-nums}
  .timer .tdot{width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 0 0 var(--warn);animation:tpulse 1.6s ease-out infinite}
  @keyframes tpulse{0%{box-shadow:0 0 0 0 #f2b23a88}70%{box-shadow:0 0 0 6px #f2b23a00}100%{box-shadow:0 0 0 0 #f2b23a00}}
  .startedat{margin-top:3px;color:var(--mut);font-size:10px;white-space:nowrap}
  .timer .staleflag{display:none}
  .timer.stale{color:var(--gate)}
  .timer.stale .tdot{background:var(--gate);box-shadow:0 0 0 0 var(--gate);animation:spulse 1.6s ease-out infinite}
  .timer.stale .staleflag{display:inline;color:var(--gate);background:#2a1512;border:1px solid #4a2620;border-radius:5px;padding:1px 5px;font-size:10px;font-weight:700}
  @keyframes spulse{0%{box-shadow:0 0 0 0 #ff6b5a88}70%{box-shadow:0 0 0 6px #ff6b5a00}100%{box-shadow:0 0 0 0 #ff6b5a00}}
  .owner{color:var(--mut);font-size:12px}
  .next{color:#cfd6e2;max-width:340px}
  .needs{max-width:320px;color:var(--mut);font-size:12px}
  .needs.gated{color:#ffd0c9;background:#2a1512;border:1px solid #4a2620;border-radius:8px;padding:7px 9px}
  .acts{white-space:nowrap;display:flex;gap:5px;flex-wrap:wrap}
  button.k{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:5px 9px;font:600 12px/1 inherit;cursor:pointer}
  button.k:hover{border-color:var(--acc)}
  button.k.done{color:var(--ok)}button.k.take{color:var(--acc)}button.k.block{color:var(--gate)}
  button.k.run{background:var(--acc);border-color:var(--acc);color:#fff;font-weight:700}
  button.k.run:hover{background:#3a7bff}
  #modal{position:fixed;inset:0;background:#000a;display:none;align-items:center;justify-content:center;z-index:20;padding:24px}
  #modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:820px;width:100%;max-height:82vh;overflow:auto;padding:18px}
  #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>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head><body>
<header>
  <h1>🎫 Ticket Action Viewer <small id="sub">loading…</small></h1>
  <div class="bar">
    <input type="search" id="q" placeholder="Search title / id / owner / project…">
    <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 rel="noopener noreferrer" class="k" href="http://127.0.0.1:9794" target="_blank" style="text-decoration:none">Board ↗</a>
  </div>
</header>
<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`;
  const projs=[...new Set(ALL.map(t=>t.project))].sort();
  el('fproject').innerHTML='<option value="">All projects</option>'+projs.map(p=>`<option>${p}</option>`).join('');
  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;}
// 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)));
  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">⚠ &gt;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>`;
}
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
// ── live "doing now" timer: elapsed since the ticket entered doing, ticking every 1s ──
function fmtElapsed(ms){if(ms<0)ms=0;const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),ss=s%60,p=n=>String(n).padStart(2,'0');
  if(d)return d+'d '+p(h)+':'+p(m);            // ≥1 day: 10d 04:07
  if(h)return h+':'+p(m)+':'+p(ss);            // ≥1 hour: 3:04:07
  return m+':'+p(ss);}                          // <1 hour: 4:07
function fmtStart(iso){try{return new Date(iso).toLocaleString([],{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return '';}}
function tickTimers(){const now=Date.now();document.querySelectorAll('.timer[data-start]').forEach(elm=>{const st=Date.parse(elm.dataset.start);const v=elm.querySelector('.tval');if(v)v.textContent='⏱ '+fmtElapsed(now-st);elm.classList.toggle('stale',(now-st)>STALE_MS);});}
setInterval(tickTimers,1000);
async function detail(id,sid){el('mtitle').textContent=sid;el('mbody').textContent='loading…';el('modal').style.display='flex';
  const r=await fetch('/api/ticket?id='+encodeURIComponent(id));const d=await r.json();el('mbody').textContent=d.out||'(no output)';}
function closeM(){el('modal').style.display='none';}
async function runNow(id,title,project){
  if(!confirm('RUN '+id.match(/^TK-\d+/)[0]+' in a NEW iTerm2 window? A fresh Claude session will take it and start work.'))return;
  toast('opening iTerm2…');
  const r=await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,title,project})});
  const d=await r.json();toast(d.ok?('▶ '+d.out):('✗ '+d.out.slice(0,90)));if(d.ok)load();}  // reload → row flips to "doing now" + timer starts
async function act(id,action){if((action==='done'||action==='block')&&!confirm(`${action} ${id.match(/^TK-\d+/)[0]}?`))return;
  const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action})});
  const d=await r.json();toast(d.ok?`✓ ${action} ok`:`✗ ${d.out.slice(0,80)}`);load();}
async function logIt(id){const text=prompt('Log note for '+id.match(/^TK-\d+/)[0]+':');if(!text)return;
  const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action:'log',text})});
  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>  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>