[object Object]

← back to Re Flyer Aggregator

sources dashboard: FULL NAV AGENT (Steve ASAP) — draggable/movable columns + column show-hide toggles + expandable rows + click Items-pulled/Deals-kept to view items in modal; sort every field incl Latest; verified all in real Chrome (TK-10708)

56f09c501329eab481ee5c19c97d65ad590d4d1c · 2026-08-19 22:59:15 -0700 · Steve Abrams

Files touched

Diff

commit 56f09c501329eab481ee5c19c97d65ad590d4d1c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 22:59:15 2026 -0700

    sources dashboard: FULL NAV AGENT (Steve ASAP) — draggable/movable columns + column show-hide toggles + expandable rows + click Items-pulled/Deals-kept to view items in modal; sort every field incl Latest; verified all in real Chrome (TK-10708)
---
 public/sources/index.html | 80 +++++++++++++++++++++++++++++++++++++----------
 1 file changed, 63 insertions(+), 17 deletions(-)

diff --git a/public/sources/index.html b/public/sources/index.html
index 1e0da1d..10e8765 100644
--- a/public/sources/index.html
+++ b/public/sources/index.html
@@ -54,6 +54,17 @@
  .mrow{padding:9px 0;border-top:1px solid #f1f3f9;font-size:13px;line-height:1.5} .mrow:first-child{border-top:none}
  .mrow .md{color:#8A5A00;font-size:12px;margin-right:6px} .mrow .mm{color:#9aa;font-size:12px} .mrow b{color:#0E7C4A}
  .mtag{display:inline-block;background:#eef0f6;color:#445;border-radius:4px;padding:1px 7px;font-size:11px;margin:2px 4px 0 0}
+ /* ---- full nav-agent table: draggable + toggleable columns, expandable rows, clickable numbers ---- */
+ thead th[draggable]{cursor:grab} thead th[draggable]:active{cursor:grabbing} thead th.act{color:var(--blue)} thead th .ar{font-size:9px}
+ thead th.dragover{background:#dbe4fb}
+ td.xc,th.xc{width:24px;color:#9aa} .car{font-size:10px;cursor:pointer}
+ tbody tr.srow{cursor:pointer} tbody tr.srow:hover>td{background:#fbfcff} tbody tr.srow.exp>td{background:#eef4ff}
+ td.num{cursor:pointer} td.num:hover{text-decoration:underline}
+ tr.detail>td{background:#f7f9ff;padding:6px 14px 12px} .dwrap{font-size:12px;color:#445} .dwrap .dm{color:#6B7280;margin-bottom:6px}
+ .dwrap .ds{padding:3px 0;border-top:1px solid #eef1f8} .dwrap .ds a{color:#16357A;text-decoration:none}
+ #nav .col{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px;color:#445}
+ #nav .col:hover{background:#eef1f8} #nav .col .sw{width:30px;height:16px;border-radius:9px;background:#ccd3e6;position:relative;transition:.15s;flex:none}
+ #nav .col.on .sw{background:#0E7C4A} #nav .col .sw::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#fff;transition:.15s} #nav .col.on .sw::after{left:16px}
  @media(max-width:820px){.grid{grid-template-columns:1fr}}
 </style>
 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
@@ -74,10 +85,7 @@
  </div>
  <h2>DATA SOURCES — RSS feeds &amp; proxies</h2>
  <div class="controls"><input id="q" placeholder="search source…" size="18"><span class="clr" id="clr">clear filters</span><div class="stat" id="sstat"></div></div>
- <table><thead><tr>
-  <th data-k="name">Source</th><th data-k="method">Method</th><th data-k="region">Market</th>
-  <th data-k="pulled">Items pulled</th><th data-k="deals">Deals kept</th><th data-k="latest">Latest</th><th>Recent from this source</th>
- </tr></thead><tbody id="tb"></tbody></table>
+ <table><thead><tr id="thead"></tr></thead><tbody id="tb"></tbody></table>
  <h2>DATABASE CRAWL — brokers (ads + flyers on their sites)</h2>
  <div class="card" id="crawl">Loading crawl status…</div>
 </div>
@@ -110,13 +118,49 @@ document.getElementById('modal').onclick=e=>{if(e.target.id==='modal')closeModal
 document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
 // ---- Nav Agent panel ----
 const NAVFIELDS=[['method','METHOD'],['region','MARKET']];
+// ---- full nav-agent columns: movable (drag) + toggleable (COLUMNS) + sortable (click) ----
+const ALLCOLS=[['name','Source'],['method','Method'],['region','Market'],['pulled','Items pulled'],['deals','Deals kept'],['latest','Latest'],['recent','Recent from this source']];
+let COLORDER=JSON.parse(localStorage.srcColOrder||'null')||ALLCOLS.map(c=>c[0]);
+let CVIS=JSON.parse(localStorage.srcColVis||'null')||ALLCOLS.reduce((o,c)=>(o[c[0]]=true,o),{});
+ALLCOLS.forEach(c=>{if(!COLORDER.includes(c[0]))COLORDER.push(c[0]);if(!(c[0] in CVIS))CVIS[c[0]]=true;}); // heal schema drift
+const clabel=k=>(ALLCOLS.find(c=>c[0]===k)||[])[1]||k;
+const cols=()=>COLORDER.filter(k=>CVIS[k]);
+const latestOf=x=>Math.max(0,...(x.samples||[]).map(s=>Date.parse(s.date)||0));
+const EXP=new Set();
+function renderHead(){const tr=document.getElementById('thead');
+ tr.innerHTML='<th class="xc"></th>'+cols().map(k=>`<th draggable="true" data-k="${k}" class="${sortK===k?'act':''}">${clabel(k)}<span class="ar">${sortK===k?(dir<0?' ▼':' ▲'):''}</span></th>`).join('');
+ tr.querySelectorAll('th[data-k]').forEach(th=>{
+  th.onclick=()=>{const k=th.dataset.k;if(sortK===k)dir*=-1;else{sortK=k;dir=(k==='name'||k==='method'||k==='region')?1:-1;}renderHead();renderSrc();};
+  th.ondragstart=e=>{e.dataTransfer.setData('text/plain',th.dataset.k);th.classList.add('act');};
+  th.ondragover=e=>{e.preventDefault();th.classList.add('dragover');};
+  th.ondragleave=()=>th.classList.remove('dragover');
+  th.ondrop=e=>{e.preventDefault();const from=e.dataTransfer.getData('text/plain'),to=th.dataset.k;
+   if(from&&to&&from!==to){const a=COLORDER.filter(c=>c!==from);a.splice(a.indexOf(to),0,from);COLORDER=a;localStorage.srcColOrder=JSON.stringify(COLORDER);}
+   renderHead();renderSrc();};
+ });}
+function cellHTML(x,k,max){
+ if(k==='name')return `<td><b>${esc(x.name)}</b></td>`;
+ if(k==='method')return `<td><span class="badge ${MB[x.method]}">${x.method}</span></td>`;
+ if(k==='region')return `<td>${esc(x.region)}</td>`;
+ if(k==='pulled')return `<td class="num" data-modal="pulled" style="color:var(--blue);font-weight:700" title="click to view the items pulled">${x.pulled}<div class="bar"><i style="width:${Math.round(x.pulled/max*100)}%"></i></div></td>`;
+ if(k==='deals')return `<td class="num" data-modal="deals" style="color:#0E7C4A;font-weight:800" title="click to view the deals kept">${x.deals||'·'}</td>`;
+ if(k==='latest')return `<td style="white-space:nowrap;color:#8A5A00">${latestOf(x)?dt(new Date(latestOf(x)).toISOString()):'—'}</td>`;
+ if(k==='recent')return `<td class="samp">${(x.samples||[]).slice(0,3).map(s=>`<div><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${esc(s.title)}</a> <span style="color:#9aa">· ${esc(s.market||'')}</span></div>`).join('')||'<span style="color:#c3c9d6">—</span>'}</td>`;
+ return '<td></td>';}
+function detailRow(x,span){
+ return `<tr class="detail"><td colspan="${span}"><div class="dwrap">
+  <div class="dm"><b>${esc(x.name)}</b> · ${x.method} · ${esc(x.region)} · pulled ${x.pulled} · kept ${x.deals||0}${x.url?` · <a href="${esc(x.url)}" target="_blank" rel="noopener">${esc(x.url.slice(0,64))}</a>`:''}</div>
+  ${(x.samples||[]).map(s=>`<div class="ds"><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener">${esc(s.title)}</a> <span style="color:#9aa">· ${esc(s.market||'')}</span></div>`).join('')||'<div class="ds" style="color:#9aa">no deal samples</div>'}</div></td></tr>`;}
 function buildNav(){ if(!SRC)return; let h='';
  for(const [key,lbl] of NAVFIELDS){ const counts={}; SRC.feeds.forEach(x=>{const v=x[key]||'—';counts[v]=(counts[v]||0)+1;});
   h+=`<div class="field">${lbl}</div>`;
   h+=Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([v,c])=>`<div class="val${F[key]===v?' on':''}" data-k="${key}" data-v="${esc(v)}"><span>${esc(v)}</span><span class="b">${c}</span></div>`).join('');
  }
+ h+=`<div class="field">COLUMNS · show / hide · drag to reorder</div>`;
+ h+=ALLCOLS.map(c=>`<div class="col${CVIS[c[0]]?' on':''}" data-col="${c[0]}"><span>${c[1]}</span><span class="sw"></span></div>`).join('');
  const nf=document.getElementById('navfields'); nf.innerHTML=h;
  nf.querySelectorAll('.val').forEach(el=>el.onclick=()=>{const k=el.dataset.k,v=el.dataset.v; F[k]=F[k]===v?'':v; buildNav(); renderSrc();});
+ nf.querySelectorAll('.col').forEach(el=>el.onclick=()=>{const c=el.dataset.col;CVIS[c]=!CVIS[c];if(!cols().length)CVIS[c]=true;localStorage.srcColVis=JSON.stringify(CVIS);buildNav();renderHead();renderSrc();});
 }
 // dock left/right + collapse (persisted)
 function applyDock(){ const nav=document.getElementById('nav'); const side=localStorage.navSide||'L', col=localStorage.navCol==='1';
@@ -139,21 +183,23 @@ function renderFlow(d){document.getElementById('flow').innerHTML=
 function renderSrc(){ if(!SRC)return;
  const q=document.getElementById('q').value.toLowerCase();
  let f=SRC.feeds.filter(x=>x.name.toLowerCase().includes(q)&&(!F.method||x.method===F.method)&&(!F.region||(x.region||'—')===F.region));
- const latestOf=x=>Math.max(0,...(x.samples||[]).map(s=>Date.parse(s.date)||0));
- f.sort((a,b)=>sortK==='name'||sortK==='method'||sortK==='region'?(''+a[sortK]).localeCompare(''+b[sortK])*dir:sortK==='latest'?(latestOf(a)-latestOf(b))*dir:((a[sortK]||0)-(b[sortK]||0))*dir);
- const max=Math.max(1,...SRC.feeds.map(x=>x.pulled));
- document.getElementById('tb').innerHTML=f.map(x=>`<tr>
-  <td><b>${esc(x.name)}</b></td>
-  <td><span class="badge ${MB[x.method]}">${x.method}</span></td><td>${esc(x.region)}</td>
-  <td>${x.pulled}<div class="bar"><i style="width:${Math.round(x.pulled/max*100)}%"></i></div></td>
-  <td style="font-weight:800;color:#0E7C4A">${x.deals||'·'}</td>
-  <td style="white-space:nowrap;color:#8A5A00">${latestOf(x)?dt(new Date(latestOf(x)).toISOString()):'—'}</td>
-  <td class="samp">${(x.samples||[]).map(s=>`<div><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener">${esc(s.title)}</a> <span style="color:#9aa">· ${esc(s.market||'')}</span></div>`).join('')||'<span style="color:#c3c9d6">—</span>'}</td>
- </tr>`).join('');
+ f.sort((a,b)=>{ if(sortK==='name'||sortK==='method'||sortK==='region')return (''+a[sortK]).localeCompare(''+b[sortK])*dir;
+  if(sortK==='latest'||sortK==='recent')return (latestOf(a)-latestOf(b))*dir; return ((a[sortK]||0)-(b[sortK]||0))*dir; });
+ const max=Math.max(1,...SRC.feeds.map(x=>x.pulled)); const ck=cols();
+ document.getElementById('tb').innerHTML=f.map(x=>{ const cells=ck.map(k=>cellHTML(x,k,max)).join('');
+  return `<tr class="srow${EXP.has(x.name)?' exp':''}" data-n="${esc(x.name)}"><td class="xc"><span class="car">${EXP.has(x.name)?'▾':'▸'}</span></td>${cells}</tr>`
+   +(EXP.has(x.name)?detailRow(x,ck.length+1):''); }).join('');
  const fil=(F.method||F.region)?` · filtered ${F.method||''} ${F.region||''}`:'';
  document.getElementById('sstat').textContent=`${f.length} of ${SRC.feeds.length} sources${fil}`;
 }
-document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{const k=th.dataset.k;if(sortK===k)dir*=-1;else{sortK=k;dir=(k==='name'||k==='method'||k==='region')?1:-1;}renderSrc();});
+// click: a number cell -> modal of its items; anywhere else on the row -> expand/collapse
+document.getElementById('tb').addEventListener('click',e=>{ if(e.target.closest('a'))return;
+ const row=e.target.closest('tr.srow'); if(!row)return; const n=row.dataset.n; const x=SRC.feeds.find(s=>s.name===n); if(!x)return;
+ const num=e.target.closest('td.num');
+ if(num){ if(num.dataset.modal==='pulled')openModal(n+' — items pulled',(x.pulled_list||[]).length,x.pulled_list||[],'deal');
+   else openModal(n+' — deals kept',(x.samples||[]).length,x.samples||[],'deal'); return; }
+ EXP.has(n)?EXP.delete(n):EXP.add(n); renderSrc();
+});
 document.getElementById('q').oninput=renderSrc;
 // ---- charts (Chart.js 2D canvas — robust, destroy-before-recreate = no leak) ----
 const charts={};
@@ -182,7 +228,7 @@ function buildCrawlCharts(cr){ if(!window.Chart)return; const done=cr.filter(r=>
  const buckets=[['Advertising',bAdv],['Flyers only',bFly],['GA only',bGa],['No signals',bNo]];
  mkDon('gCrawl',buckets.map(x=>x[0]),buckets.map(x=>x[1].length),['#0E7C4A','#E8A81C','#8aa0c0','#dde3f0'],i=>openModal('Brokers · '+buckets[i][0],buckets[i][1].length,buckets[i][1],'broker'));
 }
-function loadSrc(){fetch('/sources.json?t='+Date.now()).then(r=>r.json()).then(d=>{SRC=d;renderFlow(d);buildNav();renderSrc();buildSrcCharts();}).catch(()=>document.getElementById('sstat').textContent='sources load error');}
+function loadSrc(){fetch('/sources.json?t='+Date.now()).then(r=>r.json()).then(d=>{SRC=d;renderFlow(d);buildNav();renderHead();renderSrc();buildSrcCharts();}).catch(()=>document.getElementById('sstat').textContent='sources load error');}
 function loadCrawl(){fetch('/advertising/broker-ad-signals.json?t='+Date.now()).then(r=>r.json()).then(d=>{ CR=d;
   const done=d.filter(r=>!r.error),adv=done.filter(r=>r.paid_count>0),fly=done.filter(r=>r.flyer_count>0),ga=done.filter(r=>r.google_analytics);
   document.getElementById('crawl').innerHTML=`<div class="kv">

← c74be9d capture: add Listing type (new-to-market) + price-optional l  ·  back to Re Flyer Aggregator  ·  sources dashboard: add view-mode switch (Table/Grid/List/KV) e87dd43 →