[object Object]

← back to Gated Queue Runner

gated-queue kid-notes viewer (categorized, sort+density, created chips)

e75c3c1415a3fdc1e11d22c28275a121b5d0433a · 2026-08-18 13:13:44 -0700 · steve

Files touched

Diff

commit e75c3c1415a3fdc1e11d22c28275a121b5d0433a
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 18 13:13:44 2026 -0700

    gated-queue kid-notes viewer (categorized, sort+density, created chips)
---
 .gitignore                     |   5 ++
 .port                          |   1 +
 index.html                     | 116 +++++++++++++++++++++++++++++++++++++++++
 server.js                      | 103 ++++++++++++++++++++++++++++++++++++
 tasks/TASK-A-ga4-deploy.md     |  32 ++++++++++++
 tasks/TASK-B-osborne-prices.md |  33 ++++++++++++
 6 files changed, 290 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/.port b/.port
new file mode 100644
index 0000000..50e1196
--- /dev/null
+++ b/.port
@@ -0,0 +1 @@
+9801
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0bf3922
--- /dev/null
+++ b/index.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Things Waiting for Steve to Say Yes</title>
+<style>
+  :root { --cols: 3; --fs: 16px; }
+  * { box-sizing: border-box; }
+  body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; background: #f4f6fb; color: #1c2333; font-size: var(--fs); }
+  header { position: sticky; top: 0; z-index: 5; background: #fff; border-bottom: 2px solid #e6eaf2; padding: 14px 18px; }
+  h1 { margin: 0 0 4px; font-size: 1.5em; }
+  .sub { color: #667; font-size: .85em; }
+  .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 10px; }
+  .controls label { font-size: .8em; color: #556; display: flex; align-items: center; gap: 5px; }
+  select, input[type=search] { padding: 6px 9px; border: 1px solid #ccd3e0; border-radius: 8px; font-size: .95em; }
+  input[type=range] { vertical-align: middle; }
+  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+  .chip { border: 1px solid #d3dae8; background: #fff; border-radius: 999px; padding: 5px 12px; font-size: .82em; cursor: pointer; user-select: none; }
+  .chip.on { background: #2d5bff; color: #fff; border-color: #2d5bff; }
+  .chip .n { opacity: .7; margin-left: 4px; }
+  main { padding: 16px 18px; }
+  .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 12px; }
+  .card { background: #fff; border: 1px solid #e6eaf2; border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 1px 3px rgba(20,30,60,.05); }
+  .card .top { display: flex; align-items: center; gap: 9px; }
+  .card .emoji { font-size: 1.8em; line-height: 1; }
+  .card .cat { font-size: .72em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #2d5bff; }
+  .card .note { font-size: 1.05em; line-height: 1.45; color: #1c2333; }
+  .card .when { font-size: .72em; color: #8a93a8; display: flex; align-items: center; gap: 4px; }
+  .card .file { font-size: .68em; color: #aab; word-break: break-all; }
+  .card button { align-self: flex-start; margin-top: 2px; border: 1px solid #ccd3e0; background: #f7f9ff; border-radius: 8px; padding: 5px 10px; font-size: .8em; cursor: pointer; }
+  dialog { border: none; border-radius: 14px; max-width: 760px; width: 92vw; padding: 0; }
+  dialog .dh { padding: 12px 16px; border-bottom: 1px solid #eee; font-weight: 700; display: flex; justify-content: space-between; }
+  dialog pre { margin: 0; padding: 16px; white-space: pre-wrap; font-size: .82em; max-height: 70vh; overflow: auto; }
+  dialog .x { cursor: pointer; border: none; background: #eee; border-radius: 8px; padding: 3px 10px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>🚦 Things waiting for you to say YES</h1>
+  <div class="sub" id="sub">loading…</div>
+  <div class="controls">
+    <input type="search" id="q" placeholder="🔎 search…">
+    <label>Sort
+      <select id="sort">
+        <option value="new">Newest first</option>
+        <option value="old">Oldest first</option>
+        <option value="cat">By kind</option>
+        <option value="az">A → Z</option>
+      </select>
+    </label>
+    <label>Size <input type="range" id="dens" min="1" max="5" value="3"></label>
+  </div>
+  <div class="chips" id="chips"></div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<dialog id="dlg"><div class="dh"><span id="dt"></span><button class="x" onclick="dlg.close()">close ✕</button></div><pre id="dp"></pre></dialog>
+<script>
+const KID = { spend:'💵', send:'✉️', dns:'🌐', shopify:'🛍️', catalog:'📦', deploy:'🚀', ga4:'📊', blocked:'🔒', other:'📄' };
+let DATA = { gates: [], counts: {}, total: 0 };
+let active = new Set(); // active category filters (empty = all)
+const $ = id => document.getElementById(id);
+
+function fmtWhen(ms){ return new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+
+function load(){
+  fetch('/api/gates').then(r=>r.json()).then(d=>{
+    DATA=d; $('sub').textContent = `${d.total} things need your OK. Tap a card to read the grown-up version.`;
+    renderChips(); render();
+  });
+}
+function renderChips(){
+  const c=$('chips'); c.innerHTML='';
+  const cats = Object.entries(DATA.counts).sort((a,b)=>b[1]-a[1]);
+  cats.forEach(([k,n])=>{
+    const el=document.createElement('span');
+    el.className='chip'+(active.has(k)?' on':'');
+    el.innerHTML=`${KID[k]||'📄'} ${k}<span class="n">${n}</span>`;
+    el.onclick=()=>{ active.has(k)?active.delete(k):active.add(k); renderChips(); render(); };
+    c.appendChild(el);
+  });
+}
+function render(){
+  const q=$('q').value.toLowerCase(), sort=$('sort').value;
+  let g=DATA.gates.filter(x=> (active.size===0||active.has(x.category)) &&
+    (!q || (x.note+x.file+x.title).toLowerCase().includes(q)));
+  if(sort==='new') g.sort((a,b)=>b.created-a.created);
+  if(sort==='old') g.sort((a,b)=>a.created-b.created);
+  if(sort==='az') g.sort((a,b)=>a.title.localeCompare(b.title));
+  if(sort==='cat') g.sort((a,b)=>a.category.localeCompare(b.category)||b.created-a.created);
+  const grid=$('grid'); grid.innerHTML='';
+  g.forEach(x=>{
+    const d=document.createElement('div'); d.className='card';
+    d.innerHTML=`<div class="top"><span class="emoji">${x.emoji}</span><span class="cat">${x.catLabel}</span></div>
+      <div class="note">${esc(x.note)}</div>
+      <div class="when">🕓 ${fmtWhen(x.created)}</div>
+      <div class="file">${esc(x.file)}</div>
+      <button>read the grown-up version →</button>`;
+    d.querySelector('button').onclick=()=>openMemo(x.file,x.title);
+    grid.appendChild(d);
+  });
+  if(!g.length) grid.innerHTML='<p style="color:#889">Nothing matches.</p>';
+}
+function openMemo(f,t){ fetch('/api/memo?f='+encodeURIComponent(f)).then(r=>r.text()).then(txt=>{ $('dt').textContent=t; $('dp').textContent=txt; $('dlg').showModal(); }); }
+function esc(s){ return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
+
+// controls + persistence
+['q','sort'].forEach(id=>$(id).addEventListener('input',()=>{save();render();}));
+$('dens').addEventListener('input',e=>{ const v=+e.target.value; document.documentElement.style.setProperty('--cols', 6-v); document.documentElement.style.setProperty('--fs', (12+v*2)+'px'); localStorage.gqDens=v; });
+function save(){ localStorage.gqSort=$('sort').value; localStorage.gqQ=$('q').value; }
+(function restore(){ if(localStorage.gqSort)$('sort').value=localStorage.gqSort; if(localStorage.gqQ)$('q').value=localStorage.gqQ;
+  const v=localStorage.gqDens||3; $('dens').value=v; document.documentElement.style.setProperty('--cols',6-v); document.documentElement.style.setProperty('--fs',(12+v*2)+'px'); })();
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2a930d9
--- /dev/null
+++ b/server.js
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+// Gated-queue viewer — reads ~/.claude/yolo-queue/pending-approval, explains each gate "like a 6-year-old".
+// Zero-dependency (built-in http/fs only). Basic auth admin/DW2024!.
+const http = require('http');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const DIR = path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
+const PORT = parseInt(fs.readFileSync(path.join(__dirname, '.port'), 'utf8').trim() || '9801', 10);
+const USER = 'admin', PASS = 'DW2024!';
+
+// ---- category + kid-note logic ----
+// order matters — first match wins; specific categories before the broad "spend" catch.
+const CATS = [
+  { key: 'blocked', emoji: '🔒', label: 'Waiting on you',  rx: /blocked on (steve|scope|grant|write_themes|iam)|needs your (grant|one-time|login)|iam grant|only you can/i,
+    yes: 'This one is WAITING for you to unlock something first (a password or a permission).' },
+  { key: 'send',    emoji: '✉️', label: 'Sends messages', rx: /send-to-list|mailer|email blast|\bsend\b .*(list|vendor|customer)|george.*send|\bsms\b|push notif|draft.*email/i,
+    yes: 'If you say YES, it sends a real email or message to real people.' },
+  { key: 'dns',     emoji: '🌐', label: 'Website address', rx: /\bdns\b|a-?record|cloudflare zone|nameserver|repoint|subdomain|expose.*\.com/i,
+    yes: 'If you say YES, it changes where a website address sends people.' },
+  { key: 'catalog', emoji: '📦', label: 'Product list',    rx: /catalog stage|_catalog\b|dw_unified.*write|stage → |price (list|refresh|change)|reprice|sku|metafield|variant/i,
+    yes: 'If you say YES, it changes our big list of products (like prices or names).' },
+  { key: 'shopify', emoji: '🛍️', label: 'Store change',    rx: /shopify (write|publish|product)|customer-facing shopify|activate|draft-down|storefront|online store|collection/i,
+    yes: 'If you say YES, it changes what shoppers see in the store.' },
+  { key: 'ga4',     emoji: '📊', label: 'Visitor counter', rx: /\bga4\b|analytics|gtag|pixel|\bgtm\b/i,
+    yes: 'If you say YES, it turns on the little counter that shows how many people visit.' },
+  { key: 'deploy',  emoji: '🚀', label: 'Put on internet', rx: /deploy|kamatera|pm2 (restart|reload)|go-?live|rebuild|ship it|publish to|nginx/i,
+    yes: 'If you say YES, it puts a website change out on the internet for everyone to see.' },
+  { key: 'spend',   emoji: '💵', label: 'Spends money',   rx: /spend (real )?money|budget cap|will cost \$[1-9]|\$[1-9][0-9.]*\s*\/\s*mo|monthly \$|subscription|paid (api|plan|tier)|purchase order/i,
+    yes: 'If you say YES, it spends real money.' },
+];
+const OTHER = { key: 'other', emoji: '📄', label: 'Needs your OK', yes: 'If you say YES, it makes a change that needs your OK first.' };
+
+function categorize(body) {
+  for (const c of CATS) if (c.rx.test(body)) return c;
+  return OTHER;
+}
+function cleanTitle(firstLine, fname) {
+  let t = (firstLine || fname).replace(/^#+\s*/, '').trim();
+  // if the first line is not a real sentence/heading (looks like the filename), use filename cleanly
+  const looksLikeFname = /\.md$|\.csv$|^\d{4}[- ]\d\d[- ]\d\d/.test(t) || /GATED$|-GATED|_/.test(t);
+  if (looksLikeFname) t = fname.replace(/\.(md|csv)$/,'').replace(/[-_]/g,' ');
+  t = t.replace(/^(GATED|PENDING APPROVAL|APPROVAL|ESCALATION|FIX|DONE|READY|DRAFT|✅|🚨|🚦)\b[:\s—-]*/i, '');
+  t = t.replace(/\b20\d\d[ -]\d\d[ -]\d\d\b/g, '').replace(/\bTK[ -]?\d+\b/gi, '');
+  t = t.replace(/\b(GATED|READY|DRAFT|memo|publish held|verified)\b/gi, '');
+  t = t.replace(/[—:|].*$/, '').replace(/\(.*?\)/g, '').replace(/[_*`#>]/g, ' ').replace(/\s{2,}/g, ' ').trim();
+  if (!t) t = fname.replace(/\.(md|csv)$/, '').replace(/[-_]/g, ' ');
+  // simplify a few jargon words
+  const swap = { 'reconcile':'match up', 'remediation':'cleanup', 'canary':'watcher', 'metafield':'label',
+    'orphan':'lost', 'backfill':'fill in', 'onboard':'add', 'scrape':'collect', 'reprice':'change prices',
+    'redirect':'forward', 'provision':'set up', 'miswire':'wrong wiring', 'dedup':'remove doubles' };
+  for (const [k, v] of Object.entries(swap)) t = t.replace(new RegExp('\\b' + k + '\\w*', 'gi'), v);
+  return t.charAt(0).toUpperCase() + t.slice(1);
+}
+function kidNote(cat, title) {
+  return `This is about: ${title}. ${cat.yes}`;
+}
+
+function listGates() {
+  let files = [];
+  try { files = fs.readdirSync(DIR).filter(f => !f.startsWith('_') && (f.endsWith('.md') || f.endsWith('.csv'))); }
+  catch (e) { return []; }
+  return files.map(f => {
+    const p = path.join(DIR, f);
+    let body = '', first = '';
+    try { body = fs.readFileSync(p, 'utf8'); } catch {}
+    first = (body.split('\n').find(l => l.trim()) || '').trim();
+    const st = fs.statSync(p);
+    const cat = categorize(body);
+    const title = cleanTitle(first, f);
+    return {
+      file: f, title, category: cat.key, catLabel: cat.label, emoji: cat.emoji,
+      note: kidNote(cat, title), created: st.mtimeMs, size: st.size,
+    };
+  }).sort((a, b) => b.created - a.created);
+}
+
+const server = http.createServer((req, res) => {
+  // basic auth
+  const hdr = req.headers.authorization || '';
+  const [u, pw] = Buffer.from(hdr.split(' ')[1] || '', 'base64').toString().split(':');
+  if (u !== USER || pw !== PASS) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gates"' }); return res.end('auth');
+  }
+  if (req.url.startsWith('/api/gates')) {
+    const gates = listGates();
+    const counts = {};
+    gates.forEach(g => counts[g.category] = (counts[g.category] || 0) + 1);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify({ total: gates.length, counts, gates }));
+  }
+  if (req.url.startsWith('/api/memo')) {
+    const f = decodeURIComponent((req.url.split('?f=')[1] || ''));
+    const p = path.join(DIR, path.basename(f));
+    if (!p.startsWith(DIR) || !fs.existsSync(p)) { res.writeHead(404); return res.end('no'); }
+    res.writeHead(200, { 'Content-Type': 'text/plain' });
+    return res.end(fs.readFileSync(p, 'utf8'));
+  }
+  res.writeHead(200, { 'Content-Type': 'text/html' });
+  res.end(fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'));
+});
+server.listen(PORT, () => console.log(`gated-queue viewer on http://127.0.0.1:${PORT} (admin/DW2024!)`));
diff --git a/tasks/TASK-A-ga4-deploy.md b/tasks/TASK-A-ga4-deploy.md
new file mode 100644
index 0000000..5e96b38
--- /dev/null
+++ b/tasks/TASK-A-ga4-deploy.md
@@ -0,0 +1,32 @@
+# TASK A — Deploy the GA4 / GTM sister-sites (Steve APPROVED this run)
+
+You are a dedicated Claude session. Steve selected this gated item to RUN — execute it end-to-end,
+verify-before-acting, show $ costs, ledger reversible deploys. Work under ticket TK-10631.
+
+## Already done (do NOT redo) — verified live 2026-08-18
+The original 8-site GA4 batch is deployed: designersalesreps, fabricfriday, patterndesignlab,
+wallpaperdictionary, wallpaperhistory, wallpaperwednesday = LIVE serving their G-XXXX ids.
+STRUCTURALLY BLOCKED (skip, note only): dsgntv.com (DNS-parked, nothing served) and
+designerssalesreps.com (301-redirects to designersalesreps.com → shows the redirect target's id).
+
+## Your actual scope — the remaining GA4/GTM deploy memos in ~/.claude/yolo-queue/pending-approval/
+1. `TK-10631-ga4-dw-fleet-two-sites-deploy.md`
+2. `TK-10631-gtm-commercialwallcovering-hollywoodwallcovering-deploy.md`
+3. `TK-10631-fix-hollywoodwallcovering-miswire.md`
+4. `GA4-rollout-gated-batch.md`
+5. `GA4-fleet-provision-APPROVED.md`
+
+For EACH: read the memo, identify the site(s) + the GA4/GTM id(s) + the deploy mechanism
+(usually `/deploy` from the project dir, or a pm2 restart on Kamatera). Deploy to Kamatera.
+Then VERIFY live: `curl -sL https://<domain> | grep -oE 'G-[A-Z0-9]{10}|GTM-[A-Z0-9]+'` must return
+the expected id. Fix the hollywoodwallcovering miswire (memo 3) — confirm it serves ITS id, not a neighbor's.
+
+## Rails
+- Deploy = external publish → this run is Steve-approved, so proceed, but verify each post-deploy.
+- Ledger each deploy to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl via
+  `node ~/.claude/yolo-queue/executed-reversible/log-exec.mjs --agent ga4-deploy --ticket TK-10631 --action "..." --undo "git revert <sha> + redeploy" --blast <n> --verify "curl shows G-id" --apply`
+- Do NOT touch DNS (dsgntv parked stays parked). Do NOT create new GA4 properties (the 18-premature-properties
+  issue is a SEPARATE memo — leave it).
+- On completion, move each fully-deployed+verified memo to `_done/` and print a summary table
+  (site | id | deployed? | verified-live?).
+Show costs ($0 local deploy). Report back in this window when done.
diff --git a/tasks/TASK-B-osborne-prices.md b/tasks/TASK-B-osborne-prices.md
new file mode 100644
index 0000000..9f5de40
--- /dev/null
+++ b/tasks/TASK-B-osborne-prices.md
@@ -0,0 +1,33 @@
+# TASK B — Apply the 5 Osborne CHANGED price patterns (map Cobra colorways first) — Steve APPROVED
+
+You are a dedicated Claude session. Steve selected this gated item to RUN. This is a CANONICAL
+dw_unified write — reversible, bounded, internal (no externality). Snapshot old values BEFORE writing,
+ledger it. HOLD Shopify publish (that stays a separate gate). Work under ticket TK-10514.
+
+## Context (verified 2026-08-18)
+- Table `dw_unified.osborne_catalog` (Mac2 socket `-h /tmp`). Convention: `price_trade` = NET COST,
+  `price_retail` = round(cost/0.65/0.85, 2) = 1.81×cost. BASIS CONFIRMED (272 exact matches).
+- Source: `~/Projects/eur-recrawl/osborne-pricelists-2026-08/osborne_wallpapers_2026-08.csv`
+  (columns: base `W####`, pattern, cost/yd USD). Colorway rows in the table are `W####-##`.
+- Full finding: `~/.claude/yolo-queue/pending-approval/2026-08-18-TK-10514-osborne-STAGE-VERIFIED-publish-held.md`
+
+## The 5 CHANGED patterns (Apr-2026 list vs existing)
+| base | pattern | new cost | old trade | note |
+|---|---|---|---|---|
+| W7902 | Mayani | 216 | 333 | SINGLE cost → apply to all W7902-* colorways |
+| W7903 | Shalimar | 216 | 333 | SINGLE → apply to all W7903-* |
+| W7907 | Chellah | 326 | 435 | SINGLE → apply to all W7907-* |
+| W6302 | Cobra | **132 AND 242** | 233 | AMBIGUOUS → map colorways first |
+
+## Steps
+1. **Clean 3 (Mayani, Shalimar, Chellah):** snapshot current (id, mfr_sku, price_trade, price_retail)
+   for all `W7902-*`, `W7903-*`, `W7907-*` rows to `/tmp/osborne-rollback-B.csv`. Then UPDATE
+   `price_trade = <new cost>`, `price_retail = round(<new cost>/0.65/0.85, 2)`, `updated_at = now()`.
+2. **Cobra (W6302):** the Apr list has two costs — 132 (standard) and 242 (likely a wide-width / panel /
+   grasscloth variant). Read the source PDF text (`~/Projects/eur-recrawl/osborne-pricelists-2026-08/full.txt`)
+   around "Cobra" to see which colorway/width maps to 132 vs 242, and inspect the existing `W6302-*` rows
+   (color_name / width / material) to match. Apply per-colorway. If the mapping is NOT unambiguous from the
+   PDF + row attributes, DO NOT guess — leave Cobra unchanged and note it as still-needs-mapping in the memo.
+3. Ledger via `node ~/.claude/yolo-queue/executed-reversible/log-exec.mjs --agent osborne-prices --ticket TK-10514 --action "updated N Osborne colorway rows to Apr-2026 net cost (Mayani/Shalimar/Chellah [+Cobra if mapped])" --undo "restore from /tmp/osborne-rollback-B.csv" --blast <n> --verify "price_retail = 1.81x price_trade on updated rows" --apply`
+4. Update `...osborne-STAGE-VERIFIED-publish-held.md` with what was applied vs still-held. HOLD Shopify publish.
+Show costs ($0 local psql). Report back a table (pattern | rows updated | old→new trade | retail) in this window.

(oldest)  ·  back to Gated Queue Runner  ·  fix categorization balance + title cleanup 638e70c →