← 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
A public/drill.jsM public/index.html
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 →