← back to Approvals Viewer

rollup.js

118 lines

#!/usr/bin/env node
// Top-10 fan-out rollup — one-glance convergence board for TK-10012's 10 tasks.
// Polls the ticket board (:9794), finds the parent ticket, and derives each task's
// state from the LATEST action/comment line that mentions "task N/10". Shows a
// summary bar: opened · prepped · awaiting-GO · done. Auto-refreshes. Zero deps.
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = Number(process.env.PORT || 9793);
const PARENT = process.env.PARENT || 'TK-10012';
const BOARD = { host: '127.0.0.1', port: 9794, auth: 'admin:DW2024!' };
const PA_DIR = path.join(process.env.HOME, '.claude', 'yolo-queue', 'pending-approval');

// Real progress signal: each session stages its gated step as a NEW packet-memo in
// pending-approval (slug ends -DONE/-STAGED/-READY/-PREFLIGHT/-cutover/-revocation/
// -followups/-fix). Match each task to its packet; a packet present = prep done,
// AWAITING-GO. Keyword must avoid matching the ORIGINAL approved draft.
const TASK_MATCH = [
  /* 1 */ /^tk-10-(launch-hardening|godaddy|.*follow)|homesonspec.*(ready|hardening|packet)/i,
  /* 2 */ /^tk-22-.*(fix|ready|packet|ratelimit)/i,
  /* 3 */ /dw-signup-fulfillment.*(ready|packet|golive-ready)|go-live packet.*signup/i,
  /* 4 */ /(stale-)?needs-image.*(done|staged|ready|packet)/i,
  /* 5 */ /tk10002.*(phase2|governance|ready|done)|sku-collision.*(ready|done|packet)/i,
  /* 6 */ /gmc-remediation.*(preflight|done|ready)|gmc.*preflight-done/i,
  /* 7 */ /tk-50-.*(staged|backfill|ready|done)|provenance.*(staged|ready)/i,
  /* 8 */ /(sample-only|tk-?17).*(ready|done|staged|packet)/i,
  /* 9 */ /(de-?badge|prwn).*(ready|done|staged|packet)/i,
  /* 10 */ /york.*(ready|done|staged|packet|cadence-go)/i,
];
function packetMemos() {
  try { return fs.readdirSync(PA_DIR).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)); }
  catch { return []; }
}

function boardTickets() {
  return new Promise((resolve) => {
    const req = http.get({ host: BOARD.host, port: BOARD.port, path: '/api/tickets',
      headers: { Authorization: 'Basic ' + Buffer.from(BOARD.auth).toString('base64') } }, (r) => {
      let b = ''; r.on('data', c => b += c); r.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve([]); } });
    });
    req.on('error', () => resolve([]));
    req.setTimeout(4000, () => { req.destroy(); resolve([]); });
  });
}
function classify(text) {
  const s = (text || '').toLowerCase();
  if (/\b(done|shipped|executed|landed|complete[d]?|✅)\b/.test(s)) return 'done';
  if (/\b(await|go\s*\/\s*no|go-?no|gated|needs steve|awaiting steve|blocked|approve to|steve.?s go)\b/.test(s)) return 'awaiting-GO';
  if (/\b(prepped|staged|dry-?run|verified|ready|preconditions|diagnos)\b/.test(s)) return 'prepped';
  return 'opened';
}
async function rollup() {
  const t = (await boardTickets()).find(x => x.id && x.id.startsWith(PARENT));
  const tasks = {};
  if (t) {
    const acts = [].concat(t.actions || [], t.comments || [])
      .map(a => ({ ts: +new Date(a.ts), text: a.text || '' }))
      .sort((a, b) => a.ts - b.ts);
    for (const a of acts) {
      const m = a.text.match(/task\s*(\d{1,2})\s*\/\s*10/i);
      if (m) { const n = +m[1]; tasks[n] = { text: a.text, ts: a.ts, state: classify(a.text) }; }
    }
  }
  const memos = packetMemos();
  const rows = [];
  for (let n = 1; n <= 10; n++) {
    const row = Object.assign({ n, state: 'opened', text: '(no update yet)', ts: 0 }, tasks[n] || {});
    // packet-memo present overrides the ticket-log state → prep done, awaiting Steve's GO
    const packet = memos.find(m => TASK_MATCH[n - 1].test(m));
    if (packet) { row.state = 'awaiting-GO'; row.packet = packet; row.text = 'packet staged → ' + packet; }
    rows.push(row);
  }
  return { found: !!t, id: t ? t.id : PARENT, status: t ? t.status : '?', rows };
}

const STATECOLOR = { opened: '#64748b', prepped: '#3b82f6', 'awaiting-GO': '#d97706', done: '#16a34a' };
const esc = s => String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));

http.createServer(async (req, res) => {
  if (req.url === '/api/rollup') {
    const r = await rollup();
    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(r));
  }
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end(`<!doctype html><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Top-10 rollup</title><style>
body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:#0b1020;color:#e7ecf5}
.wrap{max-width:900px;margin:0 auto;padding:18px}h1{font-size:18px;margin:0 0 2px}.sub{color:#94a3b8;font-size:13px}
.bar{display:flex;gap:10px;margin:14px 0;flex-wrap:wrap}
.pill{padding:8px 14px;border-radius:10px;background:#0f172a;border:1px solid #1e293b;font-weight:700}
.row{display:flex;gap:12px;align-items:flex-start;background:#0f172a;border:1px solid #1e293b;border-radius:10px;padding:10px 14px;margin:8px 0}
.n{font-weight:700;color:#64748b;min-width:28px}.st{font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;white-space:nowrap}
.tx{color:#cbd5e1;font-size:13px;flex:1}.ts{color:#475569;font-size:11px}
</style><div class=wrap>
<h1>🎯 Top-10 approved-drafts fan-out</h1><div class=sub id=head>loading…</div>
<div class=bar id=bar></div><div id=list></div>
<div class=sub style="margin-top:14px">auto-refresh 5s · board <a style=color:#60a5fa href="http://127.0.0.1:9794">:9794</a></div>
</div><script>
const TITLES=${JSON.stringify([
 'HomesOnSpec prod cutover','Consulting portal deploy','DW signup/sample/rep go-live',
 'Strip stale Needs-Image (16k)','Live SKU collisions fix','GMC feed remediation',
 'Prod provenance migration','Sample-only backlog → sellable','De-badge 36 PR items','York cadence (622)'])};
const C=${JSON.stringify(STATECOLOR)};
async function tick(){
 const r=await (await fetch('/api/rollup')).json();
 document.getElementById('head').textContent=(r.found?r.id+' · ticket '+r.status:'ticket not found ('+r.id+')');
 const c={opened:0,prepped:0,'awaiting-GO':0,done:0};r.rows.forEach(x=>c[x.state]++);
 document.getElementById('bar').innerHTML=Object.keys(c).map(k=>'<span class=pill style="color:'+C[k]+'">'+c[k]+' '+k+'</span>').join('');
 document.getElementById('list').innerHTML=r.rows.map(x=>'<div class=row><div class=n>'+x.n+'</div>'
  +'<span class=st style="background:'+C[x.state]+'22;color:'+C[x.state]+'">'+x.state+'</span>'
  +'<div class=tx><b>'+esc(TITLES[x.n-1]||'')+'</b><br><span class=ts>'+(x.text||'').slice(0,180)+'</span></div></div>').join('');
}
function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
tick();setInterval(tick,5000);
</script>`);
}).listen(PORT, () => console.log('rollup on http://localhost:' + PORT + ' (parent ' + PARENT + ')'));