← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →