← back to Trending Dw
features: multi-select facets + filter chips, Gap-score badges vs pattern-vault catalog, gaps-first sort, Lanes rollup view, price->DW-tier mapper, ?view= param, staged weekly refresh job (fix: restore dropped closing tags)
14d87e4867aa358b7a2598a4fc9cf79d1f7e35ac · 2026-07-02 11:35:11 -0700 · Steve
Files touched
A ops/com.steve.trending-dw-refresh.plistM public/index.htmlA scripts/refresh.shM server.js
Diff
commit 14d87e4867aa358b7a2598a4fc9cf79d1f7e35ac
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 2 11:35:11 2026 -0700
features: multi-select facets + filter chips, Gap-score badges vs pattern-vault catalog, gaps-first sort, Lanes rollup view, price->DW-tier mapper, ?view= param, staged weekly refresh job (fix: restore dropped closing tags)
---
ops/com.steve.trending-dw-refresh.plist | 12 ++
public/index.html | 205 ++++++++++++++++++--------------
scripts/refresh.sh | 10 ++
server.js | 74 ++++++++----
4 files changed, 189 insertions(+), 112 deletions(-)
diff --git a/ops/com.steve.trending-dw-refresh.plist b/ops/com.steve.trending-dw-refresh.plist
new file mode 100644
index 0000000..c4e3d2f
--- /dev/null
+++ b/ops/com.steve.trending-dw-refresh.plist
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- STAGED, NOT LOADED. Weekly refresh of the trending bestseller board.
+ Load (Steve-gated, recurring metered spend once wired to live Exa):
+ launchctl bootstrap gui/$(id -u) ~/Projects/trending-dw/ops/com.steve.trending-dw-refresh.plist -->
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+ <key>Label</key><string>com.steve.trending-dw-refresh</string>
+ <key>ProgramArguments</key><array><string>/bin/bash</string><string>/Users/stevestudio2/Projects/trending-dw/scripts/refresh.sh</string></array>
+ <key>StartCalendarInterval</key><dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
+ <key>StandardOutPath</key><string>/tmp/trending-dw-refresh.log</string>
+ <key>StandardErrorPath</key><string>/tmp/trending-dw-refresh.log</string>
+</dict></plist>
diff --git a/public/index.html b/public/index.html
index 760478b..910f276 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,75 +5,87 @@
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Trending — Designer Wallcoverings</title>
<style>
- :root{ --cols: 4; --ink:#1c1a17; --paper:#faf8f3; --line:#e4ded2; --accent:#2f5d3a; --muted:#8a8272; --chip:#f0ebe0; }
+ :root{ --cols:4; --ink:#1c1a17; --paper:#faf8f3; --line:#e4ded2; --accent:#2f5d3a; --gap:#b6392f; --muted:#8a8272; --chip:#f0ebe0; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:"Helvetica Neue",Arial,sans-serif; color:var(--ink); background:var(--paper); -webkit-font-smoothing:antialiased; }
- a{ color:inherit; }
header.top{ display:flex; align-items:baseline; gap:18px; padding:18px 26px 14px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--paper); z-index:20; }
header.top .wm{ font-weight:800; letter-spacing:.14em; text-transform:uppercase; font-size:15px; }
header.top .wm small{ display:block; font-weight:400; letter-spacing:.22em; font-size:9px; color:var(--muted); margin-top:3px; }
header.top .tag{ font-size:12px; color:var(--muted); font-style:italic; }
- .layout{ display:grid; grid-template-columns: 258px 1fr; align-items:start; }
- /* LEFT AMAZON-STYLE PANEL */
+ .layout{ display:grid; grid-template-columns:258px 1fr; align-items:start; }
aside{ position:sticky; top:64px; align-self:start; height:calc(100vh - 64px); overflow:auto; padding:16px 14px 40px; border-right:1px solid var(--line); }
- aside .search{ width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:7px; font-size:13px; margin-bottom:14px; background:#fff; }
- .facet{ margin-bottom:16px; }
+ aside .search{ width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:7px; font-size:13px; margin-bottom:12px; background:#fff; }
+ .facet{ margin-bottom:15px; }
.facet h4{ margin:0 0 7px; font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
.facet a{ display:flex; justify-content:space-between; gap:8px; padding:4px 6px; font-size:12.5px; text-decoration:none; border-radius:5px; cursor:pointer; }
.facet a:hover{ background:#fff; }
.facet a.active{ background:var(--accent); color:#fff; font-weight:600; }
+ .facet a.active.gapf{ background:var(--gap); }
.facet a .c{ color:var(--muted); font-variant-numeric:tabular-nums; }
- .facet a.active .c{ color:#e8f0ea; }
- .reset{ font-size:11px; color:var(--accent); cursor:pointer; text-decoration:underline; }
- /* MAIN */
+ .facet a.active .c{ color:#fff; opacity:.85; }
main{ padding:16px 26px 60px; }
- .bar{ display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:16px; }
- .bar .count{ font-size:13px; color:var(--muted); }
- .bar .count b{ color:var(--ink); }
- .bar label{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin-right:6px; }
+ .bar{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:10px; }
+ .bar .count{ font-size:13px; color:var(--muted); } .bar .count b{ color:var(--ink); }
+ .bar .gapcount{ font-size:12px; color:var(--gap); font-weight:700; }
+ .bar label{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin-right:5px; }
.bar select{ padding:7px 9px; border:1px solid var(--line); border-radius:7px; font-size:13px; background:#fff; }
.bar .dens{ display:flex; align-items:center; gap:8px; margin-left:auto; }
- .bar input[type=range]{ width:130px; accent-color:var(--accent); }
+ .bar input[type=range]{ width:120px; accent-color:var(--accent); }
+ .toggle{ display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+ .toggle button{ border:0; background:#fff; padding:7px 13px; font-size:12px; cursor:pointer; color:var(--muted); }
+ .toggle button.on{ background:var(--accent); color:#fff; }
+ .chipsbar{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; min-height:0; }
+ .fchip{ font-size:11px; background:var(--ink); color:#fff; padding:4px 9px; border-radius:20px; cursor:pointer; }
+ .fchip:hover{ background:var(--gap); }
.grid{ display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; }
.card{ border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#fff; display:flex; flex-direction:column; transition:box-shadow .15s, transform .15s; }
.card:hover{ box-shadow:0 8px 24px rgba(0,0,0,.09); transform:translateY(-2px); }
.swatch{ aspect-ratio:4/3; position:relative; }
.swatch .ribbon{ position:absolute; top:10px; left:-30px; transform:rotate(-45deg); background:#b6392f; color:#fff; font-size:10px; letter-spacing:.14em; padding:3px 34px; font-weight:700; }
- .swatch .rank{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.55); color:#fff; font-size:10px; padding:2px 7px; border-radius:20px; letter-spacing:.05em; }
+ .swatch .rank{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.55); color:#fff; font-size:10px; padding:2px 7px; border-radius:20px; }
+ .swatch .cov{ position:absolute; top:9px; right:9px; font-size:9.5px; font-weight:800; letter-spacing:.08em; padding:3px 8px; border-radius:20px; }
+ .cov.gap{ background:var(--gap); color:#fff; } .cov.covered{ background:rgba(255,255,255,.82); color:var(--accent); }
.body{ padding:11px 12px 13px; display:flex; flex-direction:column; gap:6px; flex:1; }
.title{ font-size:14px; font-weight:700; line-height:1.25; }
- .company{ font-size:12px; color:var(--ink); }
- .company b{ color:var(--accent); }
+ .company{ font-size:12px; } .company b{ color:var(--accent); }
.chips{ display:flex; flex-wrap:wrap; gap:5px; margin-top:2px; }
- .chip{ font-size:10px; padding:3px 7px; border-radius:20px; background:var(--chip); color:#5a5346; letter-spacing:.03em; }
+ .chip{ font-size:10px; padding:3px 7px; border-radius:20px; background:var(--chip); color:#5a5346; }
.chip.mk{ background:#1c1a17; color:#fff; }
- .signal{ font-size:11.5px; color:var(--muted); margin-top:auto; }
- .signal .dot{ color:var(--accent); }
- .src{ font-size:11px; color:var(--accent); text-decoration:none; margin-top:4px; }
+ .signal{ font-size:11.5px; color:var(--muted); } .signal .dot{ color:var(--accent); }
+ .tier{ font-size:11px; color:var(--ink); background:#f6f2e8; border:1px dashed var(--line); border-radius:6px; padding:3px 7px; margin-top:auto; }
+ .src{ font-size:11px; color:var(--accent); text-decoration:none; }
.src:hover{ text-decoration:underline; }
- #sentinel{ height:40px; }
- .loading{ text-align:center; color:var(--muted); font-size:12px; padding:20px; }
+ /* lanes table */
+ table.lanes{ width:100%; border-collapse:collapse; font-size:13px; }
+ table.lanes th{ text-align:left; font-size:10px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); border-bottom:1px solid var(--line); padding:8px 10px; }
+ table.lanes td{ padding:9px 10px; border-bottom:1px solid var(--line); }
+ table.lanes tr:hover td{ background:#fff; }
+ .pill{ font-size:10px; font-weight:700; padding:3px 8px; border-radius:20px; }
+ .pill.gap{ background:var(--gap); color:#fff; } .pill.covered{ background:#e6ede7; color:var(--accent); }
+ .bars{ display:inline-block; height:8px; border-radius:4px; background:var(--accent); vertical-align:middle; }
+ #sentinel{ height:40px; } .loading{ text-align:center; color:var(--muted); font-size:12px; padding:20px; }
@media(max-width:820px){ .layout{ grid-template-columns:1fr; } aside{ position:static; height:auto; border-right:none; border-bottom:1px solid var(--line); } }
</style>
</head>
<body>
<header class="top">
<div class="wm">Designer Wallcoverings<small>Trending Intelligence</small></div>
- <div class="tag">Bestsellers across Google & print-on-demand — who's selling what, right now</div>
+ <div class="tag">Bestsellers across Google & print-on-demand — who's selling what, and where we have a gap</div>
</header>
<div class="layout">
<aside>
<input class="search" id="search" placeholder="Search item, company, style…" />
- <div style="margin-bottom:12px"><span class="reset" id="reset">✕ Clear all filters</span></div>
<div id="facets"></div>
</aside>
<main>
<div class="bar">
- <div class="count"><b id="cnt">0</b> bestseller data points</div>
- <div>
- <label>Sort</label>
+ <div class="count"><b id="cnt">0</b> data points</div>
+ <div class="gapcount" id="gapcnt"></div>
+ <div class="toggle"><button id="vGrid" class="on">Grid</button><button id="vLanes">Lanes</button></div>
+ <div><label>Sort</label>
<select id="sort">
<option value="signal">Bestseller signal</option>
+ <option value="gap">Gaps first</option>
<option value="newest">Newest</option>
<option value="titleAZ">Title A–Z</option>
<option value="company">Company</option>
@@ -82,96 +94,111 @@
</div>
<div class="dens"><label>Density</label><input type="range" id="dens" min="2" max="6" step="1" value="4"/></div>
</div>
+ <div class="chipsbar" id="chipsbar"></div>
<div class="grid" id="grid"></div>
+ <div id="lanes"></div>
<div id="sentinel"></div>
<div class="loading" id="loading" style="display:none">Loading…</div>
</main>
</div>
<script>
+const store = k => { try{ return JSON.parse(localStorage.getItem(k)); }catch(e){ return null; } };
const state = {
- filters: JSON.parse(localStorage.getItem('tdw_filters')||'{}'),
- q: '', sort: localStorage.getItem('tdw_sort')||'signal',
- page: 1, size: 12, total: 0, loading: false, done: false
+ filters: store('tdw_filters2') || {}, // {dim: [values]}
+ q:'', sort: localStorage.getItem('tdw_sort')||'signal', view: localStorage.getItem('tdw_view')||'grid',
+ page:1, size:12, loading:false, done:false
};
-const DIMS = [['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
-
-function save(){ localStorage.setItem('tdw_filters', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); }
+const DIMS = [['ourCoverage','Coverage'],['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
+const has = (dim,val) => (state.filters[dim]||[]).includes(val);
+function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] = has(dim,val)? a.filter(x=>x!==val) : [...a,val]; if(!state.filters[dim].length) delete state.filters[dim]; save(); loadFacets(); chips(); refresh(); }
+function save(){ localStorage.setItem('tdw_filters2', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); localStorage.setItem('tdw_view', state.view); }
async function loadFacets(){
const f = await (await fetch('/api/facets')).json();
const el = document.getElementById('facets'); el.innerHTML='';
for (const [key,label] of DIMS){
- const rows = (f[key]||[]);
- if(!rows.length) continue;
- const box = document.createElement('div'); box.className='facet';
- box.innerHTML = '<h4>'+label+'</h4>';
+ const rows=(f[key]||[]); if(!rows.length) continue;
+ const box=document.createElement('div'); box.className='facet'; box.innerHTML='<h4>'+label+'</h4>';
for (const {value,count} of rows){
- const a = document.createElement('a');
- a.className = state.filters[key]===value ? 'active':'';
- a.innerHTML = '<span>'+value+'</span><span class="c">'+count+'</span>';
- a.onclick = ()=>{ if(state.filters[key]===value) delete state.filters[key]; else state.filters[key]=value; save(); loadFacets(); reload(); };
- box.appendChild(a);
+ const a=document.createElement('a');
+ const active=has(key,value); const gapf=(key==='ourCoverage'&&value==='gap');
+ a.className=(active?'active ':'')+(gapf?'gapf':'');
+ const lbl = value==='gap'?'🎯 Gap (we don’t have)' : value==='covered'?'✓ Covered' : value;
+ a.innerHTML='<span>'+lbl+'</span><span class="c">'+count+'</span>';
+ a.onclick=()=>toggle(key,value); box.appendChild(a);
}
el.appendChild(box);
}
}
-
-function qs(){
- const p = new URLSearchParams();
- for (const k in state.filters) p.set(k, state.filters[k]);
- if (state.q) p.set('q', state.q);
- p.set('sort', state.sort); p.set('page', state.page); p.set('size', state.size);
- return p.toString();
+function chips(){
+ const bar=document.getElementById('chipsbar'); bar.innerHTML='';
+ const entries=Object.entries(state.filters).flatMap(([d,vals])=>vals.map(v=>[d,v]));
+ if(state.q) entries.unshift(['q',state.q]);
+ for(const [d,v] of entries){ const c=document.createElement('span'); c.className='fchip'; c.textContent='✕ '+(v==='gap'?'Gap':v==='covered'?'Covered':v); c.onclick=()=>{ if(d==='q'){state.q='';document.getElementById('search').value='';} else toggle(d,v); if(d==='q'){save();chips();refresh();} }; bar.appendChild(c); }
+ if(entries.length){ const c=document.createElement('span'); c.className='fchip'; c.style.background='var(--gap)'; c.textContent='Clear all'; c.onclick=()=>{ state.filters={};state.q='';document.getElementById('search').value='';save();loadFacets();chips();refresh(); }; bar.appendChild(c); }
}
+function qs(extra){ const p=new URLSearchParams(); for(const d in state.filters) p.set(d, state.filters[d].join(',')); if(state.q) p.set('q',state.q); p.set('sort',state.sort); Object.assign; for(const k in (extra||{})) p.set(k,extra[k]); return p.toString(); }
+function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+function shade(h,p){ const n=parseInt(h.slice(1),16); let r=(n>>16)+p,g=((n>>8)&255)+p,b=(n&255)+p; r=Math.max(0,Math.min(255,r));g=Math.max(0,Math.min(255,g));b=Math.max(0,Math.min(255,b)); return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); }
function card(it){
- const c = document.createElement('div'); c.className='card';
- const grad = 'linear-gradient(135deg,'+it.dominantHex+' 0%,'+shade(it.dominantHex,-18)+' 100%)';
- c.innerHTML =
- '<div class="swatch" style="background:'+grad+'">'
- + (it.isNew?'<div class="ribbon">NEW</div>':'')
- + (it.signalRank?'<div class="rank">▲ '+it.signalRank+'</div>':'')
- + '</div>'
- + '<div class="body">'
- + '<div class="title">'+esc(it.title)+'</div>'
- + '<div class="company">by <b>'+esc(it.company)+'</b></div>'
- + '<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
- + '<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
- + (it.url?'<a class="src" href="'+it.url+'" target="_blank" rel="noopener noreferrer">View source ↗</a>':'')
- + '</div>';
+ const c=document.createElement('div'); c.className='card';
+ const grad='linear-gradient(135deg,'+it.dominantHex+' 0%,'+shade(it.dominantHex,-18)+' 100%)';
+ c.innerHTML='<div class="swatch" style="background:'+grad+'">'
+ +(it.isNew?'<div class="ribbon">NEW</div>':'')
+ +'<div class="cov '+it.ourCoverage+'">'+(it.ourCoverage==='gap'?'🎯 GAP':'✓ HAVE')+'</div>'
+ +(it.signalRank?'<div class="rank">▲ '+it.signalRank+'</div>':'')+'</div>'
+ +'<div class="body"><div class="title">'+esc(it.title)+'</div>'
+ +'<div class="company">by <b>'+esc(it.company)+'</b></div>'
+ +'<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
+ +'<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
+ +'<div class="tier">DW tier → '+esc(it.dwTier)+(it.url?' · <a class="src" href="'+it.url+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div>'
+ +'</div>';
return c;
}
-function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
-function shade(hex,p){ const n=parseInt(hex.slice(1),16); let r=(n>>16)+p, g=((n>>8)&255)+p, b=(n&255)+p; r=Math.max(0,Math.min(255,r));g=Math.max(0,Math.min(255,g));b=Math.max(0,Math.min(255,b)); return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); }
-
async function fetchPage(){
- if(state.loading||state.done) return;
+ if(state.loading||state.done||state.view!=='grid') return;
state.loading=true; document.getElementById('loading').style.display='block';
- const d = await (await fetch('/api/items?'+qs())).json();
- state.total = d.total; document.getElementById('cnt').textContent = d.total;
- const grid = document.getElementById('grid');
- d.items.forEach(it=> grid.appendChild(card(it)));
- if (state.page*state.size >= d.total) state.done=true;
+ const d=await (await fetch('/api/items?'+qs({page:state.page,size:state.size}))).json();
+ document.getElementById('cnt').textContent=d.total;
+ document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
+ d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
+ if(state.page*state.size>=d.total) state.done=true;
state.page++; state.loading=false; document.getElementById('loading').style.display='none';
}
-function reload(){ state.page=1; state.done=false; document.getElementById('grid').innerHTML=''; fetchPage(); }
+async function renderLanes(){
+ const d=await (await fetch('/api/lanes?'+qs())).json();
+ const max=Math.max(1,...d.lanes.map(l=>l.count));
+ let h='<table class="lanes"><thead><tr><th>Lane</th><th>Coverage</th><th>Avg signal</th><th>Volume</th><th>Marketplaces</th></tr></thead><tbody>';
+ for(const l of d.lanes){
+ h+='<tr><td><b>'+esc(l.lane)+'</b></td>'
+ +'<td><span class="pill '+l.coverage+'">'+(l.coverage==='gap'?'🎯 GAP':'✓ HAVE')+'</span></td>'
+ +'<td>▲ '+l.avgSignal+'</td>'
+ +'<td><span class="bars" style="width:'+(l.count/max*120)+'px"></span> '+l.count+'</td>'
+ +'<td style="color:var(--muted);font-size:12px">'+l.marketplaces.map(esc).join(', ')+'</td></tr>';
+ }
+ h+='</tbody></table><p style="color:var(--muted);font-size:12px;margin-top:12px">Our catalog: '+d.ourCatalog+' designs. Lanes marked 🎯 GAP are selling across marketplaces but thin/absent in our catalog — the whitespace.</p>';
+ document.getElementById('lanes').innerHTML=h;
+}
+function setView(v){ state.view=v; save();
+ document.getElementById('vGrid').className=v==='grid'?'on':''; document.getElementById('vLanes').className=v==='lanes'?'on':'';
+ document.getElementById('grid').style.display=v==='grid'?'grid':'none';
+ document.getElementById('lanes').style.display=v==='lanes'?'block':'none';
+ document.getElementById('sentinel').style.display=v==='grid'?'block':'none';
+ if(v==='lanes') renderLanes(); else refresh();
+}
+function refresh(){ if(state.view==='lanes'){ renderLanes(); return; } state.page=1; state.done=false; document.getElementById('grid').innerHTML=''; fetchPage(); }
-// density
-const dens=document.getElementById('dens');
-dens.value = localStorage.getItem('tdw_cols')||4;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = ()=>{ document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('tdw_cols', dens.value); };
-// sort
-const sortSel=document.getElementById('sort'); sortSel.value=state.sort;
-sortSel.onchange=()=>{ state.sort=sortSel.value; save(); reload(); };
-// search (debounced)
-let t; document.getElementById('search').oninput=(e)=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); reload(); },250); };
-// reset
-document.getElementById('reset').onclick=()=>{ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); loadFacets(); reload(); };
-// infinite scroll
-new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) fetchPage(); },{rootMargin:'400px'}).observe(document.getElementById('sentinel'));
+const dens=document.getElementById('dens'); dens.value=localStorage.getItem('tdw_cols')||4;
+document.documentElement.style.setProperty('--cols',dens.value);
+dens.oninput=()=>{ document.documentElement.style.setProperty('--cols',dens.value); localStorage.setItem('tdw_cols',dens.value); };
+const sortSel=document.getElementById('sort'); sortSel.value=state.sort; sortSel.onchange=()=>{ state.sort=sortSel.value; save(); refresh(); };
+let t; document.getElementById('search').oninput=e=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); chips(); refresh(); },250); };
+document.getElementById('vGrid').onclick=()=>setView('grid'); document.getElementById('vLanes').onclick=()=>setView('lanes');
+new IntersectionObserver(es=>{ if(es[0].isIntersecting) fetchPage(); },{rootMargin:'400px'}).observe(document.getElementById('sentinel'));
-loadFacets(); reload();
+const _v=new URLSearchParams(location.search).get('view'); if(_v==='lanes'||_v==='grid') state.view=_v;
+loadFacets(); chips(); setView(state.view);
</script>
</body>
</html>
diff --git a/scripts/refresh.sh b/scripts/refresh.sh
new file mode 100755
index 0000000..0409a32
--- /dev/null
+++ b/scripts/refresh.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# trending-dw refresh — regenerate the bestseller seed + reload.
+# NOTE: this regenerates from the committed research seed. A TRUE live refresh
+# (re-scraping Google/POD via Exa) runs through the design-trend-scout agent,
+# which is metered — wire that as the gated next step (show + log cost per run).
+set -e
+cd "$(dirname "$0")/.."
+node scripts/seed.js
+if command -v pm2 >/dev/null 2>&1 && pm2 id trending-dw >/dev/null 2>&1; then pm2 restart trending-dw; fi
+echo "refreshed $(date)"
diff --git a/server.js b/server.js
index bba5d4e..0454b5d 100644
--- a/server.js
+++ b/server.js
@@ -1,7 +1,6 @@
#!/usr/bin/env node
// trending.designerwallcoverings.com — bestseller-intelligence dashboard.
// Zero-dependency Node http server. Basic-auth gated (admin:DW2024!) by default.
-// Aggregates POD/Google bestseller data points from data/bestsellers.json.
const http = require('http');
const fs = require('fs');
const path = require('path');
@@ -9,11 +8,29 @@ const path = require('path');
const PORT = process.env.PORT || 9951;
const PUBLIC = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data', 'bestsellers.json');
-const AUTH = process.env.BASIC_AUTH === undefined ? 'admin:DW2024!' : process.env.BASIC_AUTH; // BASIC_AUTH='' disables (public go-live is gated)
+const CATALOG = path.join(__dirname, '..', 'pattern-vault', 'data', 'catalog.json'); // our own catalog (for gap scoring)
+const AUTH = process.env.BASIC_AUTH === undefined ? 'admin:DW2024!' : process.env.BASIC_AUTH; // BASIC_AUTH='' disables (public go-live is gated)
const MIME = { '.html':'text/html', '.js':'application/javascript', '.css':'text/css', '.json':'application/json', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
-function loadItems(){ try { return JSON.parse(fs.readFileSync(DATA,'utf8')).items || []; } catch(e){ return []; } }
+// Styles that are demonstrably selling but ABSENT/thin in our own catalog = the whitespace.
+// Derived from the 2026-07-02 gap analysis against pattern-vault/data/catalog.json.
+const GAP_STYLES = new Set([
+ 'Animal-Skin Print', 'Art-Deco Geometric', 'Texture-Effect', 'Boho', 'Tropical',
+ 'Retro Psychedelic', 'Mushroom / Goblincore', 'Paisley / Nomadic', 'Abstract Blur', 'Retro Geometric'
+]);
+
+// price band -> suggested DW Pattern Vault license tier
+function dwTier(band, style){
+ if (/mural|panorama|scenic/i.test(style||'')) return 'Mural — $795';
+ return { '$':'Digital — $149', '$$':'Digital — $149', '$$$':'Commercial — $495', '$$$$':'Exclusive — $2500' }[band] || 'Digital — $149';
+}
+
+function enrich(it){ return { ...it, ourCoverage: GAP_STYLES.has(it.style) ? 'gap' : 'covered', dwTier: dwTier(it.priceBand, it.style) }; }
+function loadItems(){ try { return (JSON.parse(fs.readFileSync(DATA,'utf8')).items || []).map(enrich); } catch(e){ return []; } }
+
+// our own catalog coverage summary (best-effort; for the Lanes view "we have N")
+function ourCatalogCount(){ try { return (JSON.parse(fs.readFileSync(CATALOG,'utf8'))||[]).length; } catch(e){ return 0; } }
function facets(items){
const dim = (key) => {
@@ -21,54 +38,65 @@ function facets(items){
for (const it of items){ const v = it[key]; if(!v) continue; m[v] = (m[v]||0)+1; }
return Object.entries(m).sort((a,b)=>b[1]-a[1]).map(([value,count])=>({value,count}));
};
- return { marketplace: dim('marketplace'), company: dim('company'), style: dim('style'), color: dim('color'), priceBand: dim('priceBand') };
+ return { ourCoverage: dim('ourCoverage'), marketplace: dim('marketplace'), company: dim('company'), style: dim('style'), color: dim('color'), priceBand: dim('priceBand') };
+}
+
+function applyFilters(items, sp){
+ for (const key of ['ourCoverage','marketplace','company','style','color','priceBand']){
+ const raw = sp.get(key);
+ if (!raw) continue;
+ const vals = raw.split(',').filter(Boolean); // multi-select: comma-separated
+ if (vals.length) items = items.filter(it => vals.includes(it[key]));
+ }
+ const search = (sp.get('q')||'').toLowerCase();
+ if (search) items = items.filter(it => (it.title+' '+it.company+' '+it.style+' '+it.marketplace).toLowerCase().includes(search));
+ return items;
}
function send(res, code, body, type){ res.writeHead(code, { 'Content-Type': type||'application/json', 'Cache-Control':'no-store' }); res.end(body); }
const server = http.createServer((req,res)=>{
- // basic auth gate
if (AUTH){
- const hdr = req.headers.authorization || '';
const expect = 'Basic ' + Buffer.from(AUTH).toString('base64');
- if (hdr !== expect){ res.writeHead(401, { 'WWW-Authenticate':'Basic realm="trending-dw"' }); return res.end('Auth required'); }
+ if ((req.headers.authorization||'') !== expect){ res.writeHead(401, { 'WWW-Authenticate':'Basic realm="trending-dw"' }); return res.end('Auth required'); }
}
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/facets'){ return send(res, 200, JSON.stringify(facets(loadItems()))); }
- if (u.pathname === '/api/items'){
- let items = loadItems();
- const q = (k)=> (u.searchParams.get(k)||'').toLowerCase();
- for (const key of ['marketplace','company','style','color','priceBand']){
- const val = u.searchParams.get(key);
- if (val) items = items.filter(it => (it[key]||'') === val);
+ if (u.pathname === '/api/lanes'){ // trend-lane rollup
+ const items = applyFilters(loadItems(), u.searchParams);
+ const m = {};
+ for (const it of items){
+ const k = it.style; (m[k] = m[k] || { lane:k, count:0, sumSignal:0, coverage:it.ourCoverage, markets:new Set() });
+ m[k].count++; m[k].sumSignal += (it.signalRank||0); m[k].markets.add(it.marketplace);
}
- const search = q('q');
- if (search) items = items.filter(it => (it.title+' '+it.company+' '+it.style+' '+it.marketplace).toLowerCase().includes(search));
+ const lanes = Object.values(m).map(l => ({ lane:l.lane, count:l.count, avgSignal: Math.round(l.sumSignal/l.count), coverage:l.coverage, marketplaces:[...l.markets] }))
+ .sort((a,b)=> b.avgSignal-a.avgSignal || b.count-a.count);
+ return send(res, 200, JSON.stringify({ lanes, ourCatalog: ourCatalogCount() }));
+ }
+
+ if (u.pathname === '/api/items'){
+ let items = applyFilters(loadItems(), u.searchParams);
const sort = u.searchParams.get('sort') || 'signal';
const cmp = {
signal: (a,b)=> (b.signalRank||0)-(a.signalRank||0),
+ gap: (a,b)=> (a.ourCoverage===b.ourCoverage?0:(a.ourCoverage==='gap'?-1:1)) || (b.signalRank||0)-(a.signalRank||0),
titleAZ: (a,b)=> a.title.localeCompare(b.title),
company: (a,b)=> a.company.localeCompare(b.company),
marketplace: (a,b)=> a.marketplace.localeCompare(b.marketplace),
newest: (a,b)=> (b.spottedAt||'').localeCompare(a.spottedAt||'')
- }[sort] || ((a,b)=>0);
+ }[sort] || (()=>0);
items = items.slice().sort(cmp);
const page = parseInt(u.searchParams.get('page')||'1',10);
const size = parseInt(u.searchParams.get('size')||'12',10);
const total = items.length;
- const paged = items.slice((page-1)*size, page*size);
- return send(res, 200, JSON.stringify({ total, page, size, items: paged }));
+ return send(res, 200, JSON.stringify({ total, page, size, gapCount: items.filter(i=>i.ourCoverage==='gap').length, items: items.slice((page-1)*size, page*size) }));
}
- // static
let p = u.pathname === '/' ? '/index.html' : u.pathname;
const fp = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[\/\\])+/,''));
- fs.readFile(fp, (err,buf)=>{
- if (err){ return send(res, 404, 'Not found', 'text/plain'); }
- send(res, 200, buf, MIME[path.extname(fp)] || 'application/octet-stream');
- });
+ fs.readFile(fp, (err,buf)=> err ? send(res, 404, 'Not found', 'text/plain') : send(res, 200, buf, MIME[path.extname(fp)] || 'application/octet-stream'));
});
server.listen(PORT, ()=> console.log('trending-dw on http://127.0.0.1:'+PORT+(AUTH?' (basic-auth '+AUTH.split(':')[0]+')':' (PUBLIC)')));
← 9098c9e widen data: +Society6/Redbubble/Creative Market/Patternbank
·
back to Trending Dw
·
deploy: live at https://trending.designerwallcoverings.com ( 45f6d98 →