[object Object]

← back to Dw Cadence Next2

dw-cadence-next2: adopt href-to-deeper-data primitives (TK-10093)

e2bb25e3b46da1a52f819cd6f197b14a6076bf04 · 2026-08-01 20:23:35 -0700 · Steve Abrams

Files touched

Diff

commit e2bb25e3b46da1a52f819cd6f197b14a6076bf04
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:23:35 2026 -0700

    dw-cadence-next2: adopt href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   | 36 ++++++++++++++++++++++++++
 public/index.html | 75 +++++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 104 insertions(+), 7 deletions(-)

diff --git a/public/drill.js b/public/drill.js
new file mode 100644
index 0000000..e63cdd3
--- /dev/null
+++ b/public/drill.js
@@ -0,0 +1,36 @@
+// ── href-to-deeper-data primitives — SINGLE SOURCE OF TRUTH (HARD rule, 2026-07-31) ──
+// Every displayed data point must be an href to its deeper (filtered) data. These are the
+// reusable atoms; a viewer's index.html loads this file, then defines FIELDS, FIL, $, esc,
+// and applyFilters (which these reference at call time via the shared classic-script global
+// scope). Canonical copy: ~/Projects/_shared/web/drill.js → each viewer's public/drill.js.
+// Memory: all-data-points-href-to-deeper-data. Reference viewers: astek-landing, schumacher-internal.
+
+// Build the URL for a given filter set — any count/atom becomes a real, shareable link.
+function qstr(fil, q, pat) {
+  const u = new URLSearchParams();
+  for (const [k] of FIELDS) { if (fil[k]) u.set(k, fil[k]); }
+  if (q) u.set('q', q); if (pat) u.set('pat', pat);
+  const s = u.toString(); return s ? ('?' + s) : location.pathname;
+}
+// Read the URL's filter state back into FIL + the search inputs (deep-link / back-button).
+function readURL() {
+  const u = new URLSearchParams(location.search);
+  for (const [k] of FIELDS) FIL[k] = u.get(k) || '';
+  $('#q').value = u.get('q') || ''; $('#pattern').value = u.get('pat') || '';
+}
+// Reflect the current filter state into the URL (push = new history entry, else replace).
+function writeURL(push) {
+  const url = qstr(FIL, $('#q').value.trim(), $('#pattern').value.trim());
+  history[push ? 'pushState' : 'replaceState']({}, '', url);
+}
+// drill(field,value,label,cls) → an <a> whose href IS the filtered view for that value.
+// cmd/ctrl/middle-click opens it in a new tab; plain click filters in place (delegated listener).
+function drill(field, val, label, cls) {
+  if (val == null || val === '') return '';
+  const href = qstr({ ...FIL, [field]: String(val) }, $('#q').value.trim(), $('#pattern').value.trim());
+  return `<a class="drill ${cls || ''}" href="${href}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label != null ? esc(label) : esc(val)}</a>`;
+}
+// Atom drill target: SET the filter to this value (drill in), vs pickF which TOGGLES.
+function setF(k, v) { if (FIL[k] === String(v)) return; FIL[k] = String(v); applyFilters('push'); }
+
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.drill = drill; window.setF = setF;
diff --git a/public/index.html b/public/index.html
index f7e6a36..8c2c128 100644
--- a/public/index.html
+++ b/public/index.html
@@ -31,6 +31,10 @@
              font-size: 12.5px; cursor: pointer; user-select: none; }
   .daychip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
   .daychip b { font-weight: 600; }
+  a.daychip { text-decoration: none; color: inherit; }
+  /* in-card drill atom (span, delegated) */
+  .body .drill { cursor: pointer; border-bottom: 1px dotted #b0b0b0; }
+  .body .drill:hover { color: #1a1a1a; border-bottom-color: #1a1a1a; }
   main { padding: 18px 20px 60px; }
   .day-header { grid-column: 1 / -1; margin: 20px 2px 6px; font-size: 13px; font-weight: 600; color: #333;
                 text-transform: uppercase; letter-spacing: .04em; }
@@ -86,11 +90,52 @@
 <main><div class="grid" id="grid"></div></main>
 <footer id="foot"></footer>
 
+<script src="/drill.js"></script>
 <script>
 const $ = s => document.querySelector(s);
 let DATA = { meta:{}, items:[] };
 let dayFilter = 'all';
 
+// ── href-to-deeper-data adapter (HARD rule) ──────────────────────────────────
+// VIEWER model: two client-side facets — `date` (the daychip bar) + `vendor`
+// (in-card). drill.js supplies drill()/setF()/qstr referencing FIELDS/FIL/$/esc +
+// #q/#pattern. This viewer HAS a #q search but no #pattern → inject a hidden one.
+// Cards are <a> when product_url exists, so IN-CARD atoms are <span class="drill">
+// (delegated, stopPropagation) — never nested <a>. daychips are real <a href>.
+const FIELDS = [['vendor','Vendor'],['date','Day']];
+const FIL = {}; FIELDS.forEach(([k])=>FIL[k]='');
+(function(){ if(!document.getElementById('pattern')){ const i=document.createElement('input'); i.type='hidden'; i.id='pattern'; document.body.appendChild(i);} })();
+// href for a facet/atom = current URL with field k toggled to v (+ q text)
+function n2Href(k,v){
+  const p=new URLSearchParams();
+  for(const [f] of FIELDS){ const nv=(f===k)?(FIL[f]===String(v)?'':String(v)):FIL[f]; if(nv)p.set(f,nv); }
+  const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
+  const s=p.toString(); return s?('?'+s):location.pathname;
+}
+function n2WriteURL(push){
+  const p=new URLSearchParams();
+  for(const [f] of FIELDS) if(FIL[f]) p.set(f,FIL[f]);
+  const qv=$('#q').value.trim(); if(qv)p.set('q',qv);
+  const s=p.toString(); history[push?'pushState':'replaceState']({},'',s?('?'+s):location.pathname);
+}
+function n2ReadURL(){
+  const u=new URLSearchParams(location.search);
+  for(const [f] of FIELDS) FIL[f]=u.get(f)||'';
+  dayFilter = FIL.date || 'all';
+  $('#q').value=u.get('q')||'';
+}
+// applyFilters — drill.js setF() calls this; keeps dayFilter mirrored to FIL.date
+function applyFilters(nav){
+  dayFilter = FIL.date || 'all';
+  if(nav!=='none') n2WriteURL(nav==='push');
+  buildDaybar(); render();
+}
+// in-card drill atom (card is an <a>) → <span class="drill"> with data-filter/data-v
+function dspan(field,val,label){
+  if(val==null||val==='') return esc(label!=null?label:val);
+  return `<span class="drill" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</span>`;
+}
+
 function fmtWhen(iso){
   try { return new Date(iso).toLocaleString(undefined,
     { weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); }
@@ -103,6 +148,7 @@ function render(){
   const sort = $('#sort').value;
   let items = DATA.items.slice();
   if (dayFilter !== 'all') items = items.filter(i => i.go_live_date === dayFilter);
+  if (FIL.vendor) items = items.filter(i => String(i.vendor||'') === FIL.vendor);
   if (q) items = items.filter(i =>
     (i.vendor+' '+i.title+' '+i.dw_sku).toLowerCase().includes(q));
   const cmp = {
@@ -137,7 +183,7 @@ function render(){
       ${thumb}
       <div class="body">
         <div class="ttl">${esc(i.title)||'(untitled)'}</div>
-        <div class="meta">${esc(i.vendor)}</div>
+        <div class="meta">${dspan('vendor',i.vendor,i.vendor)}</div>
         <div class="sku">${esc(i.dw_sku)||'—'}</div>
         <div class="when" title="${esc(i.go_live_at)}">🕓 ${fmtWhen(i.go_live_at)}</div>
       </div></${tag}>`;
@@ -153,11 +199,18 @@ function buildDaybar(){
       const lbl = new Date(d.date+'T12:00:00').toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric'});
       return [d.date, lbl, d.count];
     }));
-  bar.innerHTML = chips.map(([v,l,c]) =>
-    `<span class="daychip${v===dayFilter?' active':''}" data-day="${v}">${l} <b>${c}</b></span>`).join('');
-  bar.querySelectorAll('.daychip').forEach(el => el.onclick = () => {
-    dayFilter = el.dataset.day;
-    buildDaybar(); render();
+  // daychips are real <a href> facet options (outside any card) — the drill contract.
+  // 'all' clears the date facet; a date sets it. href is deep-linkable/shareable.
+  bar.innerHTML = chips.map(([v,l,c]) => {
+    const isAll = v==='all';
+    const href = isAll ? n2Href('date','') : n2Href('date',v);
+    return `<a class="daychip${((isAll&&!FIL.date)||v===FIL.date)?' active':''}" href="${href}" data-day="${esc(v)}">${l} <b>${c}</b></a>`;
+  }).join('');
+  bar.querySelectorAll('.daychip').forEach(el => el.onclick = (e) => {
+    e.preventDefault();
+    const d = el.dataset.day;
+    FIL.date = (d==='all') ? '' : d;
+    applyFilters('push');
   });
 }
 
@@ -218,8 +271,16 @@ async function boot(){
 // one-time init: wire controls + manual button + start the "last re-pull" health poll
 $('#sort').onchange = () => { localStorage.setItem('n2_sort',$('#sort').value); render(); };
 $('#density').oninput = applyDensity;
-$('#q').oninput = render;
+$('#q').oninput = () => { n2WriteURL(false); render(); };
 $('#repull').onclick = repullNow;
+// delegated in-card drill span → stopPropagation so the card <a> doesn't navigate
+$('#grid').addEventListener('click', e => {
+  const d = e.target.closest('span.drill'); if(!d) return;
+  e.preventDefault(); e.stopPropagation();
+  setF(d.dataset.filter, d.dataset.v);   // drill.js: FIL[k]=v; applyFilters('push')
+});
+addEventListener('popstate', () => { n2ReadURL(); buildDaybar(); render(); });
+n2ReadURL();                              // deep-link: seed FIL/dayFilter/q from URL
 boot().then(refreshHealth);
 setInterval(refreshHealth, 60000);
 </script>

← 0b502e6 auto-save: 2026-08-01T20:09:07 (1 files) — data/next2days.js  ·  back to Dw Cadence Next2  ·  auto-save: 2026-08-01T20:39:19 (1 files) — data/next2days.js aaba31b →