[object Object]

← back to Ticket System

board: adjustable-columns TABLE view at / (resize/reorder/toggle cols, expandable rows, search-all, sortable, density+persist+Reset) per list-builds rule; kanban→/kanban, office unchanged (TK-10429)

8ee20d87a04fe104294e0d67fe0f66c8b33d5d2f · 2026-08-10 15:00:10 -0700 · steve

Files touched

Diff

commit 8ee20d87a04fe104294e0d67fe0f66c8b33d5d2f
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 15:00:10 2026 -0700

    board: adjustable-columns TABLE view at / (resize/reorder/toggle cols, expandable rows, search-all, sortable, density+persist+Reset) per list-builds rule; kanban→/kanban, office unchanged (TK-10429)
---
 board.html | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js  |   8 ++-
 2 files changed, 174 insertions(+), 1 deletion(-)

diff --git a/board.html b/board.html
new file mode 100644
index 00000000..1d02ea96
--- /dev/null
+++ b/board.html
@@ -0,0 +1,167 @@
+<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Fleet Tickets — Table</title>
+<style>
+  :root{--bg:#0f1115;--card:#171b22;--line:#262a33;--ink:#e6e6e6;--mut:#8a93a5;--faint:#5b6270;--acc:#6db3f2;--gold:#C8A24B;
+        --open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--fs:12.5px;--rowpad:7px}
+  *{box-sizing:border-box}
+  body{margin:0;font:14px -apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--ink)}
+  header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
+  h1{font-size:15px;margin:0;letter-spacing:.06em;white-space:nowrap}
+  .pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 9px;cursor:pointer;user-select:none}
+  .pill b{color:var(--ink)} .pill.on{outline:1px solid var(--acc)}
+  .dot{width:8px;height:8px;border-radius:50%;display:inline-block}
+  .search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 11px;font-size:13px;min-width:200px}
+  .btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12px;cursor:pointer}
+  .btn:hover{border-color:var(--acc)}
+  .grow{flex:1 1 auto}
+  #dens{width:96px;accent-color:var(--acc)}
+  .tblwrap{overflow:auto;max-height:calc(100vh - 52px)}
+  table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:var(--fs);white-space:nowrap;table-layout:fixed}
+  table.g thead th{position:sticky;top:0;background:#1b2230;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;
+    padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;text-align:left;position:relative;overflow:hidden;text-overflow:ellipsis}
+  table.g thead th:hover{color:var(--ink)}
+  table.g thead th.asc::after{content:' ▲';color:var(--gold)} table.g thead th.desc::after{content:' ▼';color:var(--gold)}
+  th.dragging{opacity:.4} th.dropL{box-shadow:inset 3px 0 0 var(--gold)} th.dropR{box-shadow:inset -3px 0 0 var(--gold)}
+  th .rsz{position:absolute;right:0;top:0;width:7px;height:100%;cursor:col-resize;z-index:3}
+  table.g td{padding:var(--rowpad) 10px;border-bottom:1px solid #1c2028;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
+  tr.row{cursor:pointer} tr.row:hover td{background:#141821}
+  tr.det td{background:#10131a;white-space:normal;cursor:default}
+  .mono{font-family:ui-monospace,Menlo,monospace}
+  a.cell{color:var(--acc);text-decoration:none} a.cell:hover{text-decoration:underline}
+  .stpill{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
+  .s-open{color:var(--open)} .s-doing{color:var(--doing)} .s-blocked{color:var(--blocked)} .s-done{color:var(--done);opacity:.8}
+  .who{color:#c9a4f2;cursor:pointer} .proj{color:var(--mut);cursor:pointer}
+  .num{color:var(--mut);text-align:right}
+  .thread .c{font-size:12px;margin:4px 0;color:#c6ccd8} .thread .c i{color:var(--mut);font-style:normal;font-size:10px;margin:0 4px}
+  .c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}.c.none{color:var(--faint)}
+  .cts{color:var(--faint);font-size:10px;margin-left:6px}
+  #colmenu{position:absolute;top:44px;right:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px;display:none;z-index:10;max-height:70vh;overflow:auto;min-width:180px}
+  #colmenu.show{display:block} #colmenu label{display:flex;gap:7px;align-items:center;font-size:12px;padding:3px 6px;cursor:pointer;border-radius:6px} #colmenu label:hover{background:#1c2130}
+  #colmenu .gh{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:6px 6px 2px}
+  .dms{margin:12px 14px;background:#141821;border:1px solid var(--line);border-radius:8px}
+  .dms>summary{cursor:pointer;padding:9px 13px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
+  .convos{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:4px 13px 13px}
+  .convo{background:var(--card);border:1px solid var(--line);border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px;font-size:12px}
+  .convo .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:5px}
+  .dm{margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}.dm .arw{color:var(--mut);margin:0 3px}.dm .dtx{color:#c6ccd8;margin-top:2px}
+</style></head><body>
+<header>
+  <h1>FLEET TICKETS</h1>
+  <span id="pills"></span>
+  <input class="search" id="q" placeholder="search all fields (space = AND)…">
+  <span class="grow"></span>
+  <input type="range" id="dens" min="4" max="14" step="1" title="row density">
+  <button class="btn" id="colsBtn">▦ Columns</button>
+  <button class="btn" id="resetBtn" title="Reset columns/sort/density">↺ Reset</button>
+  <a class="btn" href="/kanban">Kanban</a><a class="btn" href="/office">Office</a>
+</header>
+<div id="colmenu"></div>
+<div class="tblwrap"><table class="g"><thead><tr id="thead"></tr></thead><tbody id="tbody"></tbody></table></div>
+<div id="dmwrap"></div>
+<script>
+const $=s=>document.querySelector(s), esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const dt=s=>{if(!s)return '—';const d=new Date(s);if(isNaN(d))return '—';return '<span title="'+esc(new Date(s).toISOString())+'">'+esc(d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))+'</span>';};
+const agoH=s=>s?((Date.now()-new Date(s).getTime())/3600000):null;
+// column defs — order here is the default order; every field sortable; g=group for the toggle menu
+const COLS=[
+ {k:'id',    l:'Ticket', g:'Core', w:150, def:1, cell:t=>`<a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))}</a>`, raw:t=>t.id},
+ {k:'status',l:'Status', g:'Core', w:96,  def:1, cell:t=>`<span class="stpill s-${t.status}"><span class="dot" style="background:currentColor"></span>${t.status}</span>`, raw:t=>t.status},
+ {k:'title', l:'Title',  g:'Core', w:420, def:1, cell:t=>esc(t.title), raw:t=>t.title},
+ {k:'project',l:'Project',g:'Core',w:150, def:1, cell:t=>t.project?`<span class="proj" onclick="setQ('${esc(t.project)}')">${esc(t.project)}</span>`:'—', raw:t=>t.project||''},
+ {k:'assignee',l:'Owner',g:'Core', w:150, def:1, cell:t=>t.assignee?`<span class="who" onclick="setQ('${esc(t.assignee)}')">${esc(t.assignee)}</span>`:'—', raw:t=>t.assignee||''},
+ {k:'updated_at',l:'Updated',g:'Time',w:150,def:1,cell:t=>dt(t.updated_at),raw:t=>t.updated_at||''},
+ {k:'created_at',l:'Created',g:'Time',w:150,def:1,cell:t=>dt(t.created_at),raw:t=>t.created_at||''},
+ {k:'age', l:'Idle(h)',g:'Time', w:80, def:1, cell:t=>{const h=agoH(lastTs(t));return `<span class="num">${h==null?'—':h.toFixed(0)}</span>`;}, raw:t=>agoH(lastTs(t))||0},
+ {k:'ncom',l:'#Cmt', g:'Activity',w:64,def:1,cell:t=>`<span class="num">${(t.comments||[]).length}</span>`,raw:t=>(t.comments||[]).length},
+ {k:'nact',l:'#Act', g:'Activity',w:64,def:1,cell:t=>`<span class="num">${(t.actions||[]).length}</span>`,raw:t=>(t.actions||[]).length},
+ {k:'last',l:'Last activity',g:'Activity',w:460,def:0,cell:t=>esc((lastText(t)||'').slice(0,160)),raw:t=>lastText(t)||''},
+];
+function lastTs(t){let ms=+new Date(t.updated_at||t.created_at||0);for(const a of (t.actions||[]))ms=Math.max(ms,+new Date(a.ts));for(const c of (t.comments||[]))ms=Math.max(ms,+new Date(c.ts));return ms;}
+function lastText(t){const acts=(t.actions||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));if(acts[0])return acts[0].text;const cs=(t.comments||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));return cs[0]?cs[0].text:'';}
+const CK=COLS.map(c=>c.k);
+const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
+let DATA=[], q='', sortKey='age', sortDir=-1;
+let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
+let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k))ORDER.push(k);
+let WID=LS('tkWid',{}), DENS=LS('tkDens',7);
+document.documentElement.style.setProperty('--rowpad',DENS+'px'); $('#dens').value=DENS;
+const ordered=()=>ORDER.map(k=>COLS.find(c=>c.k===k)).filter(c=>c&&VIS[c.k]);
+
+function renderHead(){
+  const th=$('#thead'); th.innerHTML='';
+  for(const c of ordered()){
+    const el=document.createElement('th'); el.dataset.k=c.k; el.draggable=true;
+    el.innerHTML=esc(c.l)+'<span class="rsz"></span>';
+    el.style.width=(WID[c.k]||c.w)+'px';
+    if(c.k===sortKey)el.classList.add(sortDir>0?'asc':'desc');
+    el.onclick=e=>{if(e.target.classList.contains('rsz'))return; if(sortKey===c.k)sortDir*=-1;else{sortKey=c.k;sortDir=1;} render();};
+    // resize
+    el.querySelector('.rsz').addEventListener('mousedown',e=>{e.stopPropagation();e.preventDefault();const sx=e.pageX,sw=el.offsetWidth;
+      const mv=ev=>{const w=Math.max(48,sw+ev.pageX-sx);el.style.width=w+'px';WID[c.k]=w;};
+      const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);save('tkWid',WID);};
+      document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
+    // drag reorder
+    el.addEventListener('dragstart',e=>{e.dataTransfer.setData('k',c.k);el.classList.add('dragging');});
+    el.addEventListener('dragend',()=>{el.classList.remove('dragging');document.querySelectorAll('th').forEach(x=>x.classList.remove('dropL','dropR'));});
+    el.addEventListener('dragover',e=>{e.preventDefault();const r=el.getBoundingClientRect();const after=e.clientX>r.left+r.width/2;el.classList.toggle('dropR',after);el.classList.toggle('dropL',!after);});
+    el.addEventListener('drop',e=>{e.preventDefault();const src=e.dataTransfer.getData('k');if(!src||src===c.k)return;const r=el.getBoundingClientRect();const after=e.clientX>r.left+r.width/2;
+      ORDER=ORDER.filter(k=>k!==src);let i=ORDER.indexOf(c.k);ORDER.splice(after?i+1:i,0,src);save('tkOrder',ORDER);render();});
+    th.appendChild(el);
+  }
+}
+function match(t){if(!q)return true;const hay=(t.id+' '+t.title+' '+(t.project||'')+' '+(t.assignee||'')+' '+t.status+' '+(lastText(t)||'')).toLowerCase();return q.toLowerCase().split(/\s+/).every(w=>hay.includes(w));}
+function render(){
+  renderHead();
+  const cols=ordered();
+  let rows=DATA.filter(match);
+  const sc=COLS.find(c=>c.k===sortKey);
+  rows.sort((a,b)=>{let x=sc.raw(a),y=sc.raw(b);if(typeof x==='number'&&typeof y==='number')return (x-y)*sortDir;return String(x).localeCompare(String(y))*sortDir;});
+  const tb=$('#tbody');tb.innerHTML='';
+  for(const t of rows){
+    const tr=document.createElement('tr');tr.className='row';
+    tr.innerHTML=cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
+    tr.onclick=e=>{if(e.target.closest('a,.who,.proj'))return;const nx=tr.nextElementSibling;if(nx&&nx.classList.contains('det')){nx.remove();return;}
+      const d=document.createElement('tr');d.className='det';
+      const thread=(t.comments||[]).map(c=>`<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${dt(c.ts)}</span></div>`).join('')
+        +(t.actions||[]).slice(-6).map(a=>`<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${dt(a.ts)}</span></div>`).join('')
+        +(!(t.comments||[]).length&&!(t.actions||[]).length?'<div class="c none">no comments yet</div>':'');
+      d.innerHTML=`<td colspan="${cols.length}"><div class="thread">${thread}</div></td>`;tr.after(d);};
+    tb.appendChild(tr);
+  }
+  $('#count')&&($('#count').textContent=rows.length);
+  renderPills();
+}
+function renderPills(){
+  const by={open:0,doing:0,blocked:0,done:0};for(const t of DATA)by[t.status]=(by[t.status]||0)+1;
+  const stale=DATA.filter(t=>t.status==='doing'&&agoH(lastTs(t))>24).length;
+  $('#pills').innerHTML=Object.entries(by).map(([s,n])=>`<span class="pill" onclick="setQ('${s}')"><span class="dot s-${s}" style="background:currentColor"></span>${s} <b>${n}</b></span>`).join('')
+    +`<span class="pill" title="doing >24h">⚠ stale <b>${stale}</b></span><span class="pill">total <b>${DATA.length}</b> · shown <b id="count">0</b></span>`;
+}
+function renderColMenu(){
+  const m=$('#colmenu');const groups=[...new Set(COLS.map(c=>c.g))];
+  m.innerHTML=groups.map(g=>`<div class="gh">${g}</div>`+COLS.filter(c=>c.g===g).map(c=>`<label><input type="checkbox" data-k="${c.k}" ${VIS[c.k]?'checked':''}>${esc(c.l)}</label>`).join('')).join('');
+  m.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{VIS[cb.dataset.k]=cb.checked;save('tkCols',VIS);render();});
+}
+window.setQ=v=>{$('#q').value=v;q=v;render();};
+$('#q').oninput=e=>{q=e.target.value;render();};
+$('#dens').oninput=e=>{DENS=+e.target.value;document.documentElement.style.setProperty('--rowpad',DENS+'px');save('tkDens',DENS);};
+$('#colsBtn').onclick=()=>{$('#colmenu').classList.toggle('show');};
+$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens'].forEach(k=>localStorage.removeItem(k));location.reload();};
+document.addEventListener('click',e=>{if(!e.target.closest('#colmenu,#colsBtn'))$('#colmenu').classList.remove('show');});
+function dmPanel(mm){
+  const map=new Map(mm.map(m=>[m.mid,m]));const rootOf=mid=>{let id=mid,c=map.get(mid),seen=new Set([id]);while(c&&c.re&&map.get(c.re)){if(seen.has(c.re))break;seen.add(c.re);id=c.re;c=map.get(id);}return id;};
+  const th=new Map();for(const m of mm){const r=rootOf(m.mid);(th.get(r)||th.set(r,[]).get(r)).push(m);}
+  const convos=[...th.values()].map(a=>a.sort((x,y)=>x.ts<y.ts?-1:1)).sort((a,b)=>a[a.length-1].ts<b[b.length-1].ts?1:-1).slice(0,30);
+  $('#dmwrap').innerHTML=`<details class="dms"><summary>DIRECT MESSAGES <small>${mm.length} msgs · ${convos.length} threads</small></summary><div class="convos">`
+    +(convos.map(ms=>`<div class="convo"><div class="chd">${esc([...new Set(ms.flatMap(m=>[m.from,m.to]))].filter(Boolean).join(' ⇄ '))}</div>`
+      +ms.slice(-6).map(m=>`<div class="dm"><b>${esc(m.from)}</b><span class="arw">→</span><b>${esc(m.to)}</b>${m.ticket?` <span class="mono" style="color:var(--acc);font-size:10px">${esc(m.ticket.replace(/^(TK-\d+).*/,'$1'))}</span>`:''}<div class="dtx">${esc(m.text)}</div></div>`).join('')+`</div>`).join('')||'<div class="c none" style="padding:10px">no DMs</div>')
+    +`</div></details>`;
+}
+async function load(){
+  try{
+    const [t,m]=await Promise.all([fetch('/api/tickets').then(r=>r.json()),fetch('/api/messages').then(r=>r.json())]);
+    DATA=t; render(); dmPanel(m);
+  }catch(e){$('#tbody').innerHTML=`<tr><td>load failed: ${esc(e.message)}</td></tr>`;}
+}
+renderColMenu(); load(); setInterval(load,30000);
+</script></body></html>
diff --git a/server.js b/server.js
index a79a821f..1f458bb5 100644
--- a/server.js
+++ b/server.js
@@ -29,6 +29,7 @@ function getRunning(cb) {
 }
 
 const OFFICE_HTML = path.join(__dirname, 'office.html');
+const BOARD_HTML = path.join(__dirname, 'board.html');
 
 const PORT = process.env.PORT || 9794;
 const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
@@ -109,7 +110,12 @@ http.createServer((req, res) => {
   if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(d)); }); }
   if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
   if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
-  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());
+  if (req.url === '/kanban') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(page()); }
+  // default (/) = the adjustable-columns TABLE view (Steve's list-builds rule, 2026-08-10)
+  return fs.readFile(BOARD_HTML, (e, buf) => {
+    if (e) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(page()); } // fall back to kanban
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(buf);
+  });
 }).listen(PORT, '127.0.0.1', () => {
   console.log('ticket board on :' + PORT);
   getRunning(() => {});                          // warm the pm2/sessions cache so first client fetch is instant

← e51a7362 TK-10046: pre-apply decision brief (resolves 9-vs-15 + non-D  ·  back to Ticket System  ·  board: toggle FLEET TICKETS stat pills on/off (persisted); f 79770148 →