[object Object]

← back to Trending Dw

trending: data-freshness badge + per-card spotted timestamp + gap-image pending affordance + favicon

ab31ac01075bd521aa46d26880da17445edf0d47 · 2026-07-06 21:06:06 -0700 · Steve Abrams

- /api/meta endpoint surfaces generatedAt/asOf/staleDays/imageGap (freshness is the product for a trending board)
- header badge: 'as of <date> · N days ago · N signals · N img-gap', amber>3d / red>7d staleness tiers
- per-card '🕓 spotted <date>' chip (admin-card created-date standing rule)
- 13 image-gap cards now show an intentional 'our-lane image pending' affordance instead of a bare gradient
- inline SVG favicon kills the /favicon.ico 404

Files touched

Diff

commit ab31ac01075bd521aa46d26880da17445edf0d47
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 21:06:06 2026 -0700

    trending: data-freshness badge + per-card spotted timestamp + gap-image pending affordance + favicon
    
    - /api/meta endpoint surfaces generatedAt/asOf/staleDays/imageGap (freshness is the product for a trending board)
    - header badge: 'as of <date> · N days ago · N signals · N img-gap', amber>3d / red>7d staleness tiers
    - per-card '🕓 spotted <date>' chip (admin-card created-date standing rule)
    - 13 image-gap cards now show an intentional 'our-lane image pending' affordance instead of a bare gradient
    - inline SVG favicon kills the /favicon.ico 404
---
 public/index.html | 36 ++++++++++++++++++++++++++++++++++--
 server.js         | 19 +++++++++++++++++++
 2 files changed, 53 insertions(+), 2 deletions(-)

diff --git a/public/index.html b/public/index.html
index edf759c..caa309e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
 <meta charset="utf-8"/>
 <meta name="viewport" content="width=device-width, initial-scale=1"/>
 <title>Trending — Designer Wallcoverings</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%232f5d3a'/%3E%3Cpath d='M7 21l5-8 4 5 3-4 6 7z' fill='%23faf8f3'/%3E%3Ccircle cx='11' cy='9' r='2.5' fill='%23b6392f'/%3E%3C/svg%3E"/>
 <style>
   :root{ --cols:4; --ink:#1c1a17; --paper:#faf8f3; --line:#e4ded2; --accent:#2f5d3a; --gap:#b6392f; --muted:#8a8272; --chip:#f0ebe0; }
   *{ box-sizing:border-box; }
@@ -12,6 +13,14 @@
   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; }
+  header.top .fresh{ margin-left:auto; display:flex; align-items:center; gap:8px; font-size:11px; letter-spacing:.02em; white-space:nowrap; }
+  header.top .fresh .dot{ width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
+  header.top .fresh .lbl{ color:var(--muted); } header.top .fresh .lbl b{ color:var(--ink); font-variant-numeric:tabular-nums; }
+  header.top .fresh.fresh-ok   .dot{ background:#1f7a4d; box-shadow:0 0 0 3px rgba(31,122,77,.16); }
+  header.top .fresh.fresh-warn .dot{ background:#b8860b; box-shadow:0 0 0 3px rgba(184,134,11,.18); }
+  header.top .fresh.fresh-stale .dot{ background:var(--gap); box-shadow:0 0 0 3px rgba(182,57,47,.18); }
+  header.top .fresh.fresh-warn .age, header.top .fresh.fresh-stale .age{ font-weight:700; }
+  header.top .fresh.fresh-warn .age{ color:#b8860b; } header.top .fresh.fresh-stale .age{ color:var(--gap); }
   .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:12px; background:#fff; }
@@ -45,6 +54,12 @@
   .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 .ours{ position:absolute; bottom:8px; left:9px; max-width:62%; background:rgba(0,0,0,.55); color:#fff; font-size:9.5px; padding:2px 7px; border-radius:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
   .swatch .cov{ position:absolute; top:9px; right:9px; font-size:9.5px; font-weight:800; letter-spacing:.08em; padding:3px 8px; border-radius:20px; }
+  /* pending affordance for cards with no our-own image (GAP lanes awaiting generation) */
+  .swatch .pending{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:rgba(255,255,255,.92); text-align:center; padding:12px; }
+  .swatch .pending .ico{ font-size:22px; opacity:.9; }
+  .swatch .pending .txt{ font-size:10px; letter-spacing:.06em; text-transform:uppercase; font-weight:700; }
+  .swatch .pending .sub{ font-size:9px; opacity:.8; letter-spacing:.02em; }
+  .spotted{ font-size:10px; color:var(--muted); display:flex; align-items:center; gap:4px; font-variant-numeric:tabular-nums; }
   .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; }
@@ -77,6 +92,7 @@
 <header class="top">
   <div class="wm">Designer Wallcoverings<small>Trending Intelligence</small></div>
   <div class="tag">Bestsellers across Google &amp; print-on-demand — who's selling what, and where we have a gap</div>
+  <div class="fresh" id="fresh" title="Data freshness — a true refresh is metered &amp; gated"></div>
 </header>
 <div class="layout">
   <aside>
@@ -147,11 +163,26 @@ function chips(){
 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+function fmtSpotted(d){ try{ const dt=new Date(d+'T00:00:00'); return dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return d; } }
+async function loadMeta(){
+  try{
+    const m = await (await fetch('/api/meta')).json();
+    const el = document.getElementById('fresh'); if(!el) return;
+    const d = m.staleDays==null ? null : m.staleDays;
+    const tier = d==null ? 'ok' : d<=1 ? 'ok' : d<=3 ? 'ok' : d<=7 ? 'warn' : 'stale';
+    el.className = 'fresh fresh-'+tier;
+    const age = d==null ? '—' : d===0 ? 'today' : d===1 ? '1 day ago' : d+' days ago';
+    const asOf = m.asOf ? fmtSpotted(m.asOf) : '—';
+    el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.imageGap?' · <b>'+m.imageGap+'</b> img-gap':'')+'</span>';
+    el.title = 'Data as of '+asOf+' ('+age+'). A true trend refresh is metered & gated — this board reflects the last approved research pull.';
+  }catch(e){}
+}
 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="position:relative;overflow:hidden;background:'+grad+'">'+(it.image?'<img src="'+esc(it.image)+'" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.remove()">':'')
+  const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Our-lane image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — awaiting generation':'match in progress')+'</div></div>';
+  c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'+(it.image?'<img src="'+esc(it.image)+'" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.closest(\'.swatch\').insertAdjacentHTML(\'beforeend\',\''+pending.replace(/'/g,"\\'")+'\');this.remove()">':pending)
     +(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>':'')
@@ -159,6 +190,7 @@ function card(it){
     +'<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>'
+    +(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+'</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>'
     +(it.isOurOriginal?'<button class="shopbtn" data-id="'+esc(it.id)+'">+ Put on Shopify</button>':'')
@@ -209,7 +241,7 @@ document.getElementById('vGrid').onclick=()=>setView('grid'); document.getElemen
 new IntersectionObserver(es=>{ if(es[0].isIntersecting) fetchPage(); },{rootMargin:'400px'}).observe(document.getElementById('sentinel'));
 
 const _v=new URLSearchParams(location.search).get('view'); if(_v==='lanes'||_v==='grid') state.view=_v;
-loadFacets(); chips(); setView(state.view); refreshQueueCount();
+loadFacets(); chips(); setView(state.view); refreshQueueCount(); loadMeta();
 
 /* ---- Put on Shopify: stage our-own design as a DW product (sequential DWPV SKU) ---- */
 function putOnShopify(btn){
diff --git a/server.js b/server.js
index 4c017a8..433761b 100644
--- a/server.js
+++ b/server.js
@@ -32,6 +32,23 @@ function dwTier(band, style){
 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 []; } }
 
+// freshness meta — a "trending" board must surface HOW STALE it is (the data is only
+// as good as its last refresh; a true refresh is metered+gated, so honesty here matters).
+function loadMeta(){
+  try {
+    const d = JSON.parse(fs.readFileSync(DATA,'utf8'));
+    const items = d.items || [];
+    const generatedAt = d.generatedAt || null;
+    // most recent spottedAt across items = the real "as of" date
+    const spotted = items.map(i=>i.spottedAt).filter(Boolean).sort();
+    const asOf = spotted.length ? spotted[spotted.length-1] : generatedAt;
+    const staleDays = asOf ? Math.floor((Date.now() - Date.parse(asOf)) / 86400000) : null;
+    const withImage = items.filter(i=>i.image).length;
+    return { generatedAt, asOf, staleDays, count: items.length, imagesAttachedAt: d.imagesAttachedAt || null,
+             withImage, imageGap: items.length - withImage, source: d.source || null };
+  } catch(e){ return { generatedAt:null, asOf:null, staleDays:null, count:0 }; }
+}
+
 // 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; } }
 
@@ -112,6 +129,8 @@ const server = http.createServer((req,res)=>{
 
   if (u.pathname === '/api/facets'){ return send(res, 200, JSON.stringify(facets(loadItems()))); }
 
+  if (u.pathname === '/api/meta'){ return send(res, 200, JSON.stringify(loadMeta())); }
+
   if (u.pathname === '/api/lanes'){                          // trend-lane rollup
     const items = applyFilters(loadItems(), u.searchParams);
     const m = {};

← baeb0bd trending: wire 3 generated GAP originals (TR-013/014/050 tex  ·  back to Trending Dw  ·  trending: 10-min health+opportunity tick + launchd cron (DTD 4fe938f →