← back to Approvals Viewer
approvals yes/no swipe viewer
4cc1b1d8a646dc126e6be4d17a935d82681cb94a · 2026-07-31 08:51:17 -0700 · steve
Files touched
M .gitignoreA public/index.htmlM server.js
Diff
commit 4cc1b1d8a646dc126e6be4d17a935d82681cb94a
Author: steve <steve@designerwallcoverings.com>
Date: Fri Jul 31 08:51:17 2026 -0700
approvals yes/no swipe viewer
---
.gitignore | 1 +
public/index.html | 93 +++++++++++++++++++++
server.js | 240 ++++++++++--------------------------------------------
3 files changed, 135 insertions(+), 199 deletions(-)
diff --git a/.gitignore b/.gitignore
index 1bcdbdc..559785a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
.env*
*.log
.DS_Store
+.port
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2199886
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,93 @@
+<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
+<title>Approvals — yes / no</title>
+<style>
+:root{--bg:#0e0f13;--card:#181a20;--ink:#e8e8ea;--mut:#8a8f9a;--line:#2a2d36;--ok:#1db954;--no:#e5484d;--sk:#5b6472}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;z-index:5}
+.bar{flex:1;height:8px;background:#23262f;border-radius:6px;overflow:hidden}.bar>i{display:block;height:100%;background:linear-gradient(90deg,#1db954,#3ea6ff);width:0;transition:width .25s}
+.count{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap;font-size:13px}
+.tally{font-size:13px;color:var(--mut)}.tally b{color:var(--ink)}
+main{max-width:860px;margin:0 auto;padding:18px 16px 130px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px 24px;box-shadow:0 8px 30px rgba(0,0,0,.35)}
+.chip{display:inline-block;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:999px;color:var(--mut);margin-right:8px}
+.when{font-size:12px;color:var(--mut)}
+h1.title{font-size:21px;margin:14px 0 6px;line-height:1.3}
+.file{font-size:12px;color:#6b7280;word-break:break-all;margin-bottom:14px}
+.body{border-top:1px solid var(--line);padding-top:14px;max-height:52vh;overflow:auto}
+.body h1,.body h2,.body h3{font-size:16px;margin:16px 0 6px;color:#fff}.body h1{font-size:18px}
+.body code{background:#23262f;padding:1px 5px;border-radius:5px;font-size:13px}
+.body pre{background:#101217;border:1px solid var(--line);padding:12px;border-radius:8px;overflow:auto;font-size:12.5px}
+.body table{border-collapse:collapse;margin:8px 0;font-size:13px}.body td,.body th{border:1px solid var(--line);padding:5px 9px}
+.body a{color:#3ea6ff}.body ul{padding-left:22px}.body blockquote{border-left:3px solid var(--sk);margin:8px 0;padding-left:12px;color:var(--mut)}
+.actions{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,transparent,var(--bg) 22%);padding:16px;display:flex;gap:12px;justify-content:center}
+button{border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;min-width:150px}
+.b-no{background:var(--no)}.b-sk{background:var(--sk)}.b-ok{background:var(--ok)}.b-undo{background:#333;min-width:auto;padding:14px 16px;font-size:14px}
+.hint{color:var(--mut);font-size:12px;text-align:center;margin-top:8px}
+.done{text-align:center;padding:60px 20px}.done h2{font-size:26px}
+.kbd{background:#23262f;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12px}
+</style></head><body>
+<header>
+ <strong>Approvals</strong>
+ <div class=bar><i id=bar></i></div>
+ <span class=count id=count>—</span>
+ <span class=tally id=tally></span>
+</header>
+<main id=main><div class=card><div class=body>Loading…</div></div></main>
+<div class=actions id=actions style=display:none>
+ <button class=b-undo id=undo title="Undo last (U)">↶ Undo</button>
+ <button class=b-no onclick="decide('reject')">❌ Reject <span class=kbd>←</span></button>
+ <button class=b-sk onclick="decide('defer')">⏭ Defer <span class=kbd>↓</span></button>
+ <button class=b-ok onclick="decide('approve')">✅ Approve <span class=kbd>→</span></button>
+</div>
+<script>
+let memos=[],i=0,total=0,tally={approve:0,reject:0,defer:0},lastAction=null;
+function md(s){
+ s=s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
+ s=s.replace(/```([\s\S]*?)```/g,(m,c)=>'<pre>'+c+'</pre>');
+ // tables
+ s=s.replace(/(^\|.*\|$\n?)+/gm,blk=>{const rows=blk.trim().split('\n').filter(r=>!/^\|[\s:\-|]+\|$/.test(r));
+ return '<table>'+rows.map((r,ri)=>{const cells=r.split('|').slice(1,-1);const tag=ri===0?'th':'td';
+ return '<tr>'+cells.map(c=>'<'+tag+'>'+c.trim()+'</'+tag+'>').join('')+'</tr>';}).join('')+'</table>';});
+ s=s.replace(/^### (.*)$/gm,'<h3>$1</h3>').replace(/^## (.*)$/gm,'<h2>$1</h2>').replace(/^# (.*)$/gm,'<h1>$1</h1>');
+ s=s.replace(/^\s*[-*] (.*)$/gm,'<li>$1</li>').replace(/(<li>[\s\S]*?<\/li>)/g,'<ul>$1</ul>');
+ s=s.replace(/^> (.*)$/gm,'<blockquote>$1</blockquote>');
+ s=s.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/`([^`]+)`/g,'<code>$1</code>');
+ s=s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g,'<a href="$2" target=_blank>$1</a>');
+ s=s.replace(/\n{2,}/g,'<br><br>').replace(/\n/g,'<br>');
+ return s;}
+function fmtDate(iso){return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+function render(){
+ const m=document.getElementById('main');
+ document.getElementById('bar').style.width=(total?Math.round((i)/total*100):0)+'%';
+ document.getElementById('count').textContent=(i)+' / '+total+' done';
+ document.getElementById('tally').innerHTML='✅<b>'+tally.approve+'</b> ❌<b>'+tally.reject+'</b> ⏭<b>'+tally.defer+'</b>';
+ document.getElementById('undo').style.visibility=lastAction?'visible':'hidden';
+ if(i>=memos.length){document.getElementById('actions').style.display='none';
+ m.innerHTML='<div class=done><h2>🎉 All caught up</h2><p>'+tally.approve+' approved · '+tally.reject+' rejected · '+tally.defer+' deferred.</p><p style=color:#8a8f9a>Approved memos moved to <code>_approved/</code> · rejected to <code>_rejected/</code>. Fully reversible; nothing was executed.</p></div>';return;}
+ document.getElementById('actions').style.display='flex';
+ const x=memos[i];
+ m.innerHTML='<div class=card><div><span class=chip>'+x.category.label+'</span><span class=when>🕓 '+fmtDate(x.mtime)+'</span></div>'
+ +'<h1 class=title>'+x.title.replace(/</g,'<')+'</h1><div class=file>'+x.file+'</div>'
+ +'<div class=body>'+md(x.body)+'</div></div>';
+ m.querySelector('.body').scrollTop=0;
+}
+async function decide(decision){
+ const x=memos[i];tally[decision]++;lastAction={file:x.file,decision,idx:i};
+ if(decision!=='defer'){try{await fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:x.file,decision})});}catch(e){}}
+ i++;render();
+}
+async function undo(){
+ if(!lastAction)return;const a=lastAction;
+ if(a.decision!=='defer'){try{await fetch('/api/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:a.file,decision:a.decision})});}catch(e){}}
+ tally[a.decision]--;i=a.idx;lastAction=null;render();
+}
+document.getElementById('undo').onclick=undo;
+addEventListener('keydown',e=>{
+ if(i>=memos.length)return;
+ if(e.key==='ArrowRight'||e.key==='y')decide('approve');
+ else if(e.key==='ArrowLeft'||e.key==='n')decide('reject');
+ else if(e.key==='ArrowDown'||e.key===' '||e.key==='s'){e.preventDefault();decide('defer');}
+ else if(e.key==='u')undo();
+});
+fetch('/api/memos').then(r=>r.json()).then(d=>{memos=d;total=d.length;render();});
+</script></body></html>
diff --git a/server.js b/server.js
index b7b6f81..4470e88 100644
--- a/server.js
+++ b/server.js
@@ -1,200 +1,42 @@
#!/usr/bin/env node
-// Approvals Viewer — a yes/no board for the gated pending-approval queue.
-// Reads ~/.claude/yolo-queue/pending-approval/*.md, shows each draft's gate / DTD /
-// reversibility + Claude's recommendation, and lets Steve decide Approve / Defer /
-// Block per item. Decisions are written to decisions.json AND mirrored into the
-// CNCP board state (cncp-approvals-state.json) so all surfaces agree. NOTHING is
-// executed here — this captures the decision only; execution stays separate/gated.
-// Zero external deps. Basic auth admin/DW2024!. Built 2026-07-28.
-const http = require('http');
-const fs = require('fs');
-const path = require('path');
-
-const HOME = process.env.HOME;
-const PA_DIR = path.join(HOME, '.claude', 'yolo-queue', 'pending-approval');
-const DECISIONS = path.join(HOME, '.claude', 'yolo-queue', 'decisions.json');
-const CNCP_STATE = path.join(HOME, 'cncp-starter', 'cncp-approvals-state.json');
-const RECS = path.join(__dirname, 'recs.json');
-const PORT = Number(process.env.PORT || 9792);
-const USER = 'admin', PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
-
-const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
-const saveJSON = (f, o) => { const t = f + '.tmp'; fs.writeFileSync(t, JSON.stringify(o, null, 2)); fs.renameSync(t, f); };
-
-function rateGate(b) {
- const m = (b.match(/\*\*Gate[^\n:]*:?\*\*\s*([^\n]+)/i) || b.match(/gate[:\s]+([^\n·]+)/i));
- if (m) return m[1].replace(/[*`]/g, '').trim().slice(0, 40);
- if (/DNS|domain|301-redirect|cutover|go-live/i.test(b)) return 'DNS / go-live';
- if (/send|foia|email list|constant contact|linkedin/i.test(b)) return 'send / comms';
- if (/shopify|metafield|collection|tag/i.test(b)) return 'Shopify write';
- if (/destructive|purge|delete|drop /i.test(b)) return 'destructive';
- return 'other';
-}
-function dtd(b) {
- if (/unanimous/i.test(b)) return 'unanimous';
- const m = b.match(/verdict\s+([A-C])/i);
- return m ? 'verdict ' + m[1].toUpperCase() : '';
-}
-function drafts() {
- const decs = loadJSON(DECISIONS, {});
- const recs = loadJSON(RECS, {});
- let files = [];
- try { files = fs.readdirSync(PA_DIR).filter(f => f.endsWith('.md')); } catch {}
- return files.map(f => {
- const slug = f.replace(/\.md$/, '');
- let body = ''; let mtime = 0;
- try { body = fs.readFileSync(path.join(PA_DIR, f), 'utf8'); mtime = fs.statSync(path.join(PA_DIR, f)).mtimeMs; } catch {}
- const title = (body.match(/^#\s*(.+)/m) || [0, slug])[1].replace(/[*`]/g, '').slice(0, 140);
- const rec = recs[slug] || { rec: 'defer', why: 'no recommendation on file — your call' };
- return {
- slug, title, gate: rateGate(body), dtd: dtd(body),
- reversible: /reversible|♻️/i.test(body),
- destructive: /destructive|purge|\bdelete\b|\bdrop\b/i.test(body),
- mtime, rec: rec.rec, why: rec.why,
- decision: (decs[slug] && decs[slug].decision) || 'undecided',
- body,
- };
- }).sort((a, b) => (a.decision === 'undecided' ? -1 : 1) - (b.decision === 'undecided' ? -1 : 1) || b.mtime - a.mtime);
-}
-function decide(slug, decision) {
- const decs = loadJSON(DECISIONS, {});
- if (decision === 'undecided') delete decs[slug];
- else decs[slug] = { decision, at: Date.now() };
- saveJSON(DECISIONS, decs);
- // mirror into CNCP board so the dashboard agrees (approve->done view, block->rejected, defer->pending)
- try {
- const st = loadJSON(CNCP_STATE, {});
- const map = { approve: 'done', block: 'rejected', defer: 'pending', undecided: 'pending' };
- const f = slug + '.md';
- st[f] = { status: map[decision] || 'pending', at: Date.now(), note: 'approvals-viewer: ' + decision, source: 'approvals-viewer' };
- saveJSON(CNCP_STATE, st);
- } catch {}
- return true;
-}
-
-const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
-const RECCOLOR = { approve: '#16a34a', defer: '#d97706', block: '#dc2626' };
-const DECCOLOR = { approve: '#16a34a', defer: '#6b7280', block: '#dc2626', undecided: '#334155' };
-
-function page() {
- return `<!doctype html><html><head><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
-<title>Approvals — yes / no</title><style>
-*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:#0b1020;color:#e7ecf5}
-header{position:sticky;top:0;background:#0b1020ee;backdrop-filter:blur(6px);padding:14px 18px;border-bottom:1px solid #1e293b;z-index:5}
-h1{margin:0;font-size:18px}.sub{color:#94a3b8;font-size:13px;margin-top:2px}
-.bar{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center}
-.chip{padding:4px 10px;border-radius:999px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;cursor:pointer;font-size:13px}
-.chip.on{background:#1d4ed8;border-color:#1d4ed8;color:#fff}
-.wrap{max-width:1000px;margin:0 auto;padding:16px}
-.card{background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:14px 16px;margin:10px 0}
-.card.approve{border-left:4px solid #16a34a}.card.block{border-left:4px solid #dc2626}.card.defer{border-left:4px solid #6b7280}
-.t{font-weight:600;margin:0 0 6px}.badges{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0}
-.b{font-size:11px;padding:2px 8px;border-radius:999px;background:#1e293b;color:#cbd5e1}
-.rec{font-size:13px;margin:8px 0;padding:8px 10px;background:#0b1224;border-radius:8px}
-.btns{display:flex;gap:8px;margin-top:10px}
-button{border:0;border-radius:8px;padding:8px 16px;font-weight:600;cursor:pointer;font-size:14px}
-.yes{background:#16a34a;color:#fff}.no{background:#334155;color:#e2e8f0}.blk{background:#dc2626;color:#fff}
-.sel{outline:3px solid #fff3}
-.mtoggle{margin-top:8px;color:#60a5fa;cursor:pointer;font-size:13px}
-pre{white-space:pre-wrap;background:#060a16;border:1px solid #1e293b;border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-size:12px;display:none}
-.count{font-weight:700}
-</style></head><body>
-<header><h1>🗂️ Approvals — answer yes / no</h1>
-<div class=sub id=summary>loading…</div>
-<div class=bar>
- <span class=chip data-f=undecided>Undecided</span>
- <span class=chip data-f=approve>Approved</span>
- <span class=chip data-f=defer>Deferred</span>
- <span class=chip data-f=block>Blocked</span>
- <span class=chip data-f=all>All</span>
- <span style="flex:1"></span>
- <span class=chip id=export>Copy decisions</span>
-</div></header>
-<div class=wrap id=list></div>
-<script>
-let DATA=[], FILTER='undecided';
-async function load(){ DATA=await (await fetch('/api/drafts')).json(); render(); }
-function counts(){ const c={undecided:0,approve:0,defer:0,block:0}; DATA.forEach(d=>c[d.decision]++); return c; }
-function render(){
- const c=counts();
- document.getElementById('summary').innerHTML=
- '<span class=count>'+DATA.length+'</span> drafts · '+
- '<b style="color:#f8fafc">'+c.undecided+'</b> undecided · '+
- '<b style="color:#16a34a">'+c.approve+'</b> approved · '+
- '<b style="color:#9ca3af">'+c.defer+'</b> deferred · '+
- '<b style="color:#f87171">'+c.block+'</b> blocked';
- document.querySelectorAll('.chip[data-f]').forEach(ch=>ch.classList.toggle('on',ch.dataset.f===FILTER));
- const rows=DATA.filter(d=>FILTER==='all'||d.decision===FILTER);
- document.getElementById('list').innerHTML = rows.length? rows.map(card).join('') :
- '<p style="color:#64748b;text-align:center;padding:40px">nothing here — 🎉</p>';
-}
-function card(d){
- const rc={approve:'#16a34a',defer:'#d97706',block:'#dc2626'}[d.rec]||'#94a3b8';
- return '<div class="card '+d.decision+'">'
- +'<div class=t>'+d.title+'</div>'
- +'<div class=badges>'
- +'<span class=b>⚙️ '+d.gate+'</span>'
- +(d.dtd?'<span class=b>🧑⚖️ '+d.dtd+'</span>':'')
- +(d.reversible?'<span class=b style="color:#34d399">♻️ reversible</span>':'')
- +(d.destructive?'<span class=b style="color:#f87171">⚠️ destructive</span>':'')
- +'<span class=b style="color:#64748b">'+d.slug+'</span>'
- +'</div>'
- +'<div class=rec><b style="color:'+rc+'">Claude rec: '+d.rec.toUpperCase()+'</b> — '+esc(d.why)+'</div>'
- +'<div class=btns>'
- +btn(d,'approve','yes','✅ Yes / Approve')
- +btn(d,'defer','no','⏸ No / Defer')
- +btn(d,'block','blk','⛔ Block')
- +'</div>'
- +'<div class=mtoggle onclick="tog(this)">▸ memo</div>'
- +'<pre>'+esc(d.body)+'</pre>'
- +'</div>';
-}
-function btn(d,dec,cls,label){ return '<button class="'+cls+(d.decision===dec?' sel':'')+'" data-slug="'+d.slug+'" data-dec="'+dec+'">'+label+'</button>'; }
-document.getElementById('list').addEventListener('click',function(e){
- var b=e.target.closest('button[data-slug]'); if(!b) return;
- var d=DATA.find(function(x){return x.slug===b.dataset.slug;}); var dec=b.dataset.dec;
- decide(b.dataset.slug,(d&&d.decision===dec)?'undecided':dec);
-});
-function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
-function tog(el){var p=el.nextElementSibling;p.style.display=p.style.display==="block"?"none":"block";}
-async function decide(slug,decision){
- await fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,decision})});
- const d=DATA.find(x=>x.slug===slug); if(d) d.decision=decision; render();
-}
-document.querySelectorAll('.chip[data-f]').forEach(ch=>ch.onclick=()=>{FILTER=ch.dataset.f;render();});
-document.getElementById('export').onclick=()=>{
- const lines=DATA.filter(d=>d.decision!=='undecided').map(d=>d.slug+': '+({approve:'approve',defer:'defer',block:'block'}[d.decision]));
- navigator.clipboard.writeText(lines.join('\\n')); document.getElementById('export').textContent='copied '+lines.length;
- setTimeout(()=>document.getElementById('export').textContent='Copy decisions',1500);
-};
-load();
-</script></body></html>`;
-}
-
-http.createServer((req, res) => {
- // Loopback (this Mac) is trusted — no auth, so the browser fetch never 401s.
- // Any non-local caller still needs basic auth admin/DW2024!.
- const ip = (req.socket.remoteAddress || '').replace('::ffff:', '');
- const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
- if (!isLocal) {
- const hdr = req.headers.authorization || '';
- const [, b64] = hdr.split(' ');
- const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
- if (u !== USER || p !== PASS) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm=approvals' }); return res.end('auth required'); }
- }
-
- if (req.url === '/' ) { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end(page()); }
- if (req.url === '/api/drafts') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(drafts())); }
- if (req.url === '/api/decide' && req.method === 'POST') {
- let body = ''; req.on('data', c => body += c); req.on('end', () => {
- try { const { slug, decision } = JSON.parse(body || '{}');
- if (!/^[\w.\-]+$/.test(slug || '')) throw new Error('bad slug');
- if (!['approve', 'defer', 'block', 'undecided'].includes(decision)) throw new Error('bad decision');
- decide(slug, decision);
- res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true }));
- } catch (e) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(e.message) })); }
- });
- return;
- }
- res.writeHead(404); res.end('not found');
-}).listen(PORT, () => console.log('approvals-viewer on http://localhost:' + PORT + ' (admin/DW2024!)'));
+// Approvals yes/no swipe viewer — zero-dependency. Basic-auth admin/DW2024!.
+const http=require('http'),fs=require('fs'),path=require('path');
+const QUEUE=path.join(process.env.HOME,'.claude/yolo-queue/pending-approval');
+const APPROVED=path.join(QUEUE,'_approved'),REJECTED=path.join(QUEUE,'_rejected');
+const LOG=path.join(QUEUE,'_decisions.jsonl');
+const USER='admin',PASS='DW2024!';
+for(const d of [APPROVED,REJECTED]) fs.mkdirSync(d,{recursive:true});
+function categorize(name){const n=name.toLowerCase();
+ if(/tk10045|tk-10045|critical-incident|credential|godaddy|alert-|secret-rotat|tk-10024|security-harden|git-history|blocker-a|rotation/.test(n))return{key:'security',label:'\u{1F534} Security / rotation'};
+ if(/golive|go-live|deploy|cutover|consolidation|homesonspec|spechomes|consulting-portal|nineoh|testflight|rubikscube/.test(n))return{key:'golive',label:'\u{1F7E0} Go-live / deploy'};
+ if(/kravet|eur-|reprice|reid-witlin|rebel|tk10029|sku-collision|debadge|prwn|banned-word|fentucci|versa|stale-needs|imageless|image-less|quote-only|commercial-use|maharam|stout|osborne|momentum|greenland|astek|pr-quote|price/.test(n))return{key:'catalog',label:'\u{1F7E1} Catalog / pricing'};
+ if(/linkedin|tiktok|ig-tiktok|cc-fm|cc-import|marketing|reels|social/.test(n))return{key:'marketing',label:'\u{1F7E2} Marketing / send'};
+ if(/usre|usrealestate|rentv|houston|foia|parcel|firm-resolve|listing/.test(n))return{key:'project',label:'\u{1F535} usre / rentv'};
+ if(/gmc|dedup|cadence|model-arena|swap-pressure|shopify-storage|fm-mfr/.test(n))return{key:'misc',label:'⚪ GMC / recurring'};
+ return{key:'other',label:'⚫ Other'};}
+function listMemos(){return fs.readdirSync(QUEUE).filter(f=>f.endsWith('.md')&&!f.startsWith('_')).map(f=>{
+ const fp=path.join(QUEUE,f),st=fs.statSync(fp),body=fs.readFileSync(fp,'utf8');
+ const title=(body.match(/^#\s+(.+)$/m)||[,f.replace(/\.md$/,'')])[1].slice(0,140);
+ return{file:f,title,category:categorize(f),mtime:st.mtime.toISOString(),size:st.size,body};
+}).sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));}
+function send(res,c,b,t='application/json'){res.writeHead(c,{'Content-Type':t});res.end(b);}
+http.createServer((req,res)=>{
+ const h=req.headers.authorization||'';const [u,p]=Buffer.from(h.split(' ')[1]||'','base64').toString().split(':');
+ if(u!==USER||p!==PASS){res.writeHead(401,{'WWW-Authenticate':'Basic realm="approvals"'});return res.end('auth');}
+ if(req.url==='/'||req.url==='/index.html')return send(res,200,fs.readFileSync(path.join(__dirname,'public/index.html')),'text/html');
+ if(req.url==='/healthz')return send(res,200,'ok','text/plain');
+ if(req.url==='/api/memos')return send(res,200,JSON.stringify(listMemos()));
+ if(req.url==='/api/decide'&&req.method==='POST'){let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{
+ const{file,decision}=JSON.parse(d);if(!/^[\w.\-]+\.md$/.test(file))throw new Error('bad file');
+ const src=path.join(QUEUE,file);if(!fs.existsSync(src))throw new Error('gone');
+ let dest=decision==='approve'?path.join(APPROVED,file):decision==='reject'?path.join(REJECTED,file):null;
+ if(dest)fs.renameSync(src,dest);
+ fs.appendFileSync(LOG,JSON.stringify({ts:new Date().toISOString(),file,decision})+'\n');send(res,200,'{"ok":true}');
+ }catch(e){send(res,400,JSON.stringify({error:String(e.message)}));}});return;}
+ if(req.url==='/api/undo'&&req.method==='POST'){let d='';req.on('data',c=>d+=c);req.on('end',()=>{try{
+ const{file,decision}=JSON.parse(d);const from=decision==='approve'?APPROVED:REJECTED;const src=path.join(from,file);
+ if(fs.existsSync(src))fs.renameSync(src,path.join(QUEUE,file));
+ fs.appendFileSync(LOG,JSON.stringify({ts:new Date().toISOString(),file,decision:'undo:'+decision})+'\n');send(res,200,'{"ok":true}');
+ }catch(e){send(res,400,JSON.stringify({error:String(e.message)}));}});return;}
+ send(res,404,'nf','text/plain');
+}).listen(0,'127.0.0.1',function(){const port=this.address().port;fs.writeFileSync(path.join(__dirname,'.port'),String(port));console.log('approvals-viewer http://127.0.0.1:'+port+' (admin/DW2024!)');});
← 4529ded rollup: detect gated packet-memos per task (real convergence
·
back to Approvals Viewer
·
pin approvals-swipe to stable port 9788 65b30e1 →