← back to AbramsEgo
Spend → Reviews: one-screen batch review lens + draft variety (anti-spam)
7a35bdfb6fbfe4652d439c39b04a207ee034fea5 · 2026-09-11 08:45:33 -0700 · Steve
Coordinator-approved safe compromise (human-in-the-loop, no auto-post):
- Batch review lens (index.html): one screen listing every draftable target with
its FULL draft text + rating inline, per-row checkbox + select-all + filter;
'Approve selected' approves in one pass via new /approve-batch (reuses the exact
per-item content-seal, no bypass). Selection now survives the 15s auto-refresh.
Added lens — the per-item card grid is unchanged.
- Posting stays per-item + paced + gated: new /post-approved fires the gated
executor SEQUENTIALLY (delay between each), each call re-checking EXECUTORS_LIVE
+ per-item approved + content-seal + confirm token; a drifted item is skipped
mid-run and reported. NO single-call bulk fire, NO auto-approve.
- drafts.js variety: rotating openers/structures/lengths + varied rating language
so batch-approved reviews don't read as clones; Amazon reviews made specific
from product/asin/seller. Proven on :9773 (drafts differ side-by-side; batch
approve+paced-post; drift-skip mid-run; posted stays 0 while executors gated).
TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M lib/spend-reviews/drafts.jsM lib/spend-reviews/router.jsM public/index.html
Diff
commit 7a35bdfb6fbfe4652d439c39b04a207ee034fea5
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 11 08:45:33 2026 -0700
Spend → Reviews: one-screen batch review lens + draft variety (anti-spam)
Coordinator-approved safe compromise (human-in-the-loop, no auto-post):
- Batch review lens (index.html): one screen listing every draftable target with
its FULL draft text + rating inline, per-row checkbox + select-all + filter;
'Approve selected' approves in one pass via new /approve-batch (reuses the exact
per-item content-seal, no bypass). Selection now survives the 15s auto-refresh.
Added lens — the per-item card grid is unchanged.
- Posting stays per-item + paced + gated: new /post-approved fires the gated
executor SEQUENTIALLY (delay between each), each call re-checking EXECUTORS_LIVE
+ per-item approved + content-seal + confirm token; a drifted item is skipped
mid-run and reported. NO single-call bulk fire, NO auto-approve.
- drafts.js variety: rotating openers/structures/lengths + varied rating language
so batch-approved reviews don't read as clones; Amazon reviews made specific
from product/asin/seller. Proven on :9773 (drafts differ side-by-side; batch
approve+paced-post; drift-skip mid-run; posted stays 0 while executors gated).
TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
lib/spend-reviews/drafts.js | 113 ++++++++++++++++++++++++++++----------------
lib/spend-reviews/router.js | 64 +++++++++++++++++++++----
public/index.html | 90 +++++++++++++++++++++++++++++++++++
3 files changed, 218 insertions(+), 49 deletions(-)
diff --git a/lib/spend-reviews/drafts.js b/lib/spend-reviews/drafts.js
index e9d54040..06d9341e 100644
--- a/lib/spend-reviews/drafts.js
+++ b/lib/spend-reviews/drafts.js
@@ -2,14 +2,12 @@
/**
* Draft generator — Steve's genuine positive voice, $0 local.
*
- * Composes specific, varied praise from the item's real details (merchant,
- * product) using rotating sentence banks seeded by the item id (deterministic
- * per item, varied across items), then runs the result through a local de-slop
- * pass (the stop-slop tells: elevate/unlock/tapestry/testament/seamless/
- * game-changer/"not only…but also"/em-dash spam). No LLM, no network, no cost.
- *
- * An optional LLM path can be wired later behind a flag; it would show its cost.
- * For now every draft is labeled "$0 (local)".
+ * VARIETY is a safety requirement (TK-11433): batch-approving near-identical
+ * 5-star reviews gets Google/Amazon accounts penalized. So each draft rotates
+ * its OPENER, STRUCTURE (shape), LENGTH, and RATING LANGUAGE — seeded
+ * deterministically by the item id (stable per item so the content-seal holds
+ * across re-generation, but varied across items). Amazon drafts are made
+ * SPECIFIC from product/asin/seller. No LLM, no network, labeled "$0 (local)".
*/
// ---- de-slop pass (mirrors the stop-slop linter's safe swaps) ----------------
@@ -36,80 +34,113 @@ function deslop(text) {
return t.trim();
}
-function pick(arr, seed) { return arr[Math.abs(hash(seed)) % arr.length]; }
function hash(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
+function pick(arr, seed) { return arr[Math.abs(hash(seed)) % arr.length]; }
+// ---- sentence banks (wide, so batches don't read as clones) -------------------
const OPEN = [
- 'Really happy with this one.',
- 'Genuinely pleased with the whole experience.',
- 'This worked out exactly the way I hoped.',
- 'Glad I went with them.',
- 'No complaints at all here.',
+ 'Really happy with this one.', 'Genuinely pleased with the whole experience.',
+ 'This worked out exactly the way I hoped.', 'Glad I went with them.',
+ 'No complaints at all here.', 'Exactly what I was looking for.',
+ 'Honestly exceeded what I expected.', 'A good find that I keep coming back to.',
+ 'Well worth it in the end.', 'Couldn\'t have asked for better.',
];
const MID_PRODUCT = [
'The {product} is well made and does exactly what I needed.',
'The {product} arrived in great shape and has held up well.',
'Quality on the {product} is better than I expected for the price.',
'The {product} has been solid so far and I use it often.',
+ 'The {product} matched the description and feels built to last.',
+ 'I\'ve put the {product} through real use and it hasn\'t let me down.',
];
const MID_MERCH = [
'Ordering from {merchant} was easy and everything showed up on time.',
'{merchant} made the whole thing painless from checkout to delivery.',
'Service from {merchant} was quick and straightforward.',
'{merchant} clearly cares about getting the details right.',
+ '{merchant} kept me posted the whole way and delivered as promised.',
+ 'The people at {merchant} were helpful and made it simple.',
+];
+const DETAIL = [
+ 'Packaging was tidy and nothing was damaged.',
+ 'Shipping was faster than I expected.',
+ 'Communication was clear from order to arrival.',
+ 'It was priced fairly for what you get.',
+ 'Setup was simple and the instructions were clear.',
+ 'Everything was exactly as pictured.',
];
const CLOSE = [
- 'Would order again without hesitation.',
- 'Recommend them to anyone on the fence.',
- 'Five stars from me.',
- 'Will be back for more.',
+ 'Would order again without hesitation.', 'Recommend them to anyone on the fence.',
+ 'Will be back for more.', 'Easy recommendation from me.',
+ 'I\'d happily buy from them again.', 'Solid experience start to finish.',
+];
+// varied rating language (never a single fixed "Five stars" line)
+const RATING_LINE = [
+ 'Five stars from me.', '★★★★★', '5/5, no notes.', 'Easily five stars.',
+ 'Top marks.', 'A well-earned five.', 'Rating it a full five.', 'Five out of five.',
];
function fill(t, item) {
const merch = item.display_merchant || item.merchant || 'them';
- return t.replace(/\{product\}/g, item.product || 'item')
- .replace(/\{merchant\}/g, merch);
+ return t.replace(/\{product\}/g, item.product || 'item').replace(/\{merchant\}/g, merch);
+}
+
+// choose one of a few STRUCTURES per item, plus whether to append a rating line
+function shapeGoogle(item) {
+ const s = Math.abs(hash(item.id + 'shape')) % 4;
+ const P = item.product;
+ let parts;
+ if (s === 0) parts = [pick(OPEN, item.id + 'g0'), fill(pick(MID_MERCH, item.id + 'gm'), item), pick(CLOSE, item.id + 'gc')];
+ else if (s === 1) parts = [fill(pick(MID_MERCH, item.id + 'g1'), item), pick(DETAIL, item.id + 'gd'), pick(CLOSE, item.id + 'gc')];
+ else if (s === 2) parts = [pick(OPEN, item.id + 'g2'), pick(CLOSE, item.id + 'gc')]; // short
+ else parts = [pick(OPEN, item.id + 'g3'), fill(pick(MID_MERCH, item.id + 'gm'), item), P ? fill(pick(MID_PRODUCT, item.id + 'gp'), item) : pick(DETAIL, item.id + 'gd'), pick(CLOSE, item.id + 'gc')]; // long
+ if (Math.abs(hash(item.id + 'grate')) % 3 !== 0) parts.push(pick(RATING_LINE, item.id + 'gr'));
+ return parts;
}
function genGoogleReview(item) {
- const parts = [pick(OPEN, item.id + 'g')];
- parts.push(fill(pick(MID_MERCH, item.id + 'gm'), item));
- if (item.product) parts.push(fill(pick(MID_PRODUCT, item.id + 'gp'), item));
- parts.push(pick(CLOSE, item.id + 'gc'));
- return { text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+ return { text: deslop(shapeGoogle(item).join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
}
function genAmazonReview(item) {
- const parts = [pick(OPEN, item.id + 'a')];
- parts.push(fill(pick(MID_PRODUCT, item.id + 'ap'), item));
- if (item.seller) parts.push(deslop(`${item.seller} shipped it fast and it was well packed.`));
- parts.push(pick(CLOSE, item.id + 'ac'));
- const title = deslop(item.product ? `Great ${item.product}` : 'Happy with this purchase');
- return { title, text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+ const s = Math.abs(hash(item.id + 'ashape')) % 3;
+ const prod = item.product || 'this item';
+ const specifics = [];
+ if (item.product) specifics.push(`The ${prod} is exactly as described`);
+ if (item.asin) specifics.push('it arrived quickly and well packed');
+ if (item.seller) specifics.push(`shipped by ${item.seller}`);
+ const spec = specifics.length ? deslop(specifics.join(', ') + '.') : '';
+ let parts;
+ if (s === 0) parts = [pick(OPEN, item.id + 'a0'), fill(pick(MID_PRODUCT, item.id + 'ap'), item), spec];
+ else if (s === 1) parts = [spec || fill(pick(MID_PRODUCT, item.id + 'a1'), item), pick(DETAIL, item.id + 'ad'), pick(CLOSE, item.id + 'ac')];
+ else parts = [pick(OPEN, item.id + 'a2'), spec, fill(pick(MID_PRODUCT, item.id + 'ap'), item), pick(CLOSE, item.id + 'ac')];
+ if (Math.abs(hash(item.id + 'arate')) % 3 !== 0) parts.push(pick(RATING_LINE, item.id + 'ar'));
+ const titleBank = item.product ? [`Great ${prod}`, `${prod} — exactly as described`, `Very happy with the ${prod}`, `${prod} does the job well`] : ['Happy with this purchase', 'Would buy again', 'Solid purchase'];
+ const title = deslop(pick(titleBank, item.id + 'at'));
+ return { title, text: deslop(parts.filter(Boolean).join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
}
function genSellerEmail(item) {
const who = item.seller || item.display_merchant || item.merchant || 'there';
const thing = item.product || 'the product';
- const subject = deslop(`Thank you — ${thing}`);
- const body = deslop([
- `Hi ${who},`,
- '',
+ const subjBank = [`Thank you — ${thing}`, `Really pleased with the ${thing}`, `A quick thank-you for the ${thing}`, `Great experience with the ${thing}`];
+ const subject = deslop(pick(subjBank, item.id + 'st'));
+ const openBank = [
`I recently bought ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} and wanted to say thank you. It has been great, and the quality and service both stood out.`,
- '',
+ `Just wanted to reach out about the ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} — it has worked out really well and I appreciated how smooth the whole process was.`,
+ `Thank you for the ${thing}${item.order_id ? ` (order ${item.order_id})` : ''}. It arrived in great shape and has been exactly what I needed.`,
+ ];
+ const body = deslop([
+ `Hi ${who},`, '', pick(openBank, item.id + 'sb'), '',
`I left a positive review to help other buyers find you. Keep up the good work — I'll be back.`,
- '',
- 'Best,',
- 'Steve Abrams',
+ '', 'Best,', 'Steve Abrams',
].join('\n'));
return { subject, body, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
}
function generateAll(item) {
const drafts = {};
- // Google review — applicable to every item that has a merchant.
if (item.merchant) drafts.google_review = genGoogleReview(item);
- // Amazon review + seller email — only when the Amazon target resolved to a product.
const amzOk = item.resolved && item.resolved.amazon && item.resolved.amazon.status === 'resolved';
if (amzOk) {
drafts.amazon_review = genAmazonReview(item);
diff --git a/lib/spend-reviews/router.js b/lib/spend-reviews/router.js
index 18d0f883..1008ab80 100644
--- a/lib/spend-reviews/router.js
+++ b/lib/spend-reviews/router.js
@@ -139,24 +139,72 @@ router.post('/draft/:id', (req, res) => {
res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets, approvals }) });
});
-// Per-item human approval flip (the whole point — nothing posts without this).
-function setTarget(req, res, status) {
- const { id, target } = req.body || {};
- if (!cfg.TARGETS.includes(target)) return res.status(400).json({ ok: false, error: 'unknown target' });
+// Shared per-item status flip WITH the content-seal (the whole point — nothing
+// posts without this, and approval seals EXACTLY the approved content). Used by
+// both the single-item route and the batch route so the seal is never bypassed.
+function applyStatus(id, target, status) {
+ if (!cfg.TARGETS.includes(target)) return { ok: false, error: 'unknown target' };
const it = store.getItem(id);
- if (!it) return res.status(404).json({ ok: false, error: 'not found' });
- if (status === 'approved' && (!it.drafts || !it.drafts[target])) return res.status(400).json({ ok: false, error: 'no draft to approve for this target' });
+ if (!it) return { ok: false, error: 'not found' };
+ if (status === 'approved' && (!it.drafts || !it.drafts[target])) return { ok: false, error: 'no draft to approve for this target' };
+ if (status === 'approved' && it.targets && it.targets[target] === 'skipped' && it.excluded) return { ok: false, error: 'merchant excluded (own business/infra) — cannot approve' };
const targets = Object.assign({}, it.targets, { [target]: status });
- // Seal the approved content (anti-drift). Un/skip clears the seal.
const approvals = Object.assign({}, it.approvals);
approvals[target] = status === 'approved' ? { contentHash: store.targetContentHash(it, target), approvedAt: new Date().toISOString() } : null;
store.logAction({ action: 'set-status', id, target, status });
- res.json({ ok: true, item: store.updateItem(id, { targets, approvals }) });
+ return { ok: true, item: store.updateItem(id, { targets, approvals }) };
+}
+function setTarget(req, res, status) {
+ const { id, target } = req.body || {};
+ const r = applyStatus(id, target, status);
+ if (!r.ok) return res.status(r.error === 'not found' ? 404 : 400).json(r);
+ res.json(r);
}
router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
router.post('/skip', (req, res) => setTarget(req, res, 'skipped'));
+// BATCH APPROVE — marks each selected (id,target) approved in one pass, reusing
+// the SAME per-item seal (applyStatus). This is NOT a new approval mechanism and
+// NOT an auto-approve: the caller (Steve) supplies the exact list he selected.
+router.post('/approve-batch', (req, res) => {
+ const list = (req.body && req.body.items) || [];
+ if (!Array.isArray(list) || !list.length) return res.status(400).json({ ok: false, error: 'items[] required' });
+ const results = list.slice(0, 500).map(({ id, target }) => {
+ const r = applyStatus(id, target, 'approved');
+ return { id, target, ok: r.ok, error: r.error || null };
+ });
+ res.json({ ok: true, approved: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results });
+});
+
+// POST APPROVED — fires the gated executor SEQUENTIALLY, one item at a time, with
+// a delay between each. NOT a bulk fire: every call still passes through
+// executeTarget, so it needs EXECUTORS_LIVE + per-item approved + a matching
+// content-seal + confirm token. A drifted/blocked item is skipped and reported;
+// the run continues. If EXECUTORS_LIVE is off, every item comes back gated
+// (nothing sent). The caller supplies the selected list, or omit to act on all
+// currently-approved targets.
+router.post('/post-approved', async (req, res) => {
+ const delayMs = Math.min(10000, Math.max(300, Number(req.body && req.body.delayMs) || 1500));
+ let list = (req.body && req.body.items) || null;
+ if (!Array.isArray(list) || !list.length) {
+ list = [];
+ for (const it of store.readItems()) for (const t of cfg.TARGETS) if (it.targets && it.targets[t] === 'approved') list.push({ id: it.id, target: t });
+ }
+ const results = [];
+ for (const { id, target } of list.slice(0, 500)) {
+ const it = store.getItem(id);
+ if (!it) { results.push({ id, target, status: 'error', reason: 'not found' }); continue; }
+ const r = await executors.executeTarget(it, target, { live: true, confirm: id });
+ if (r.ok && r.status === 'posted') store.updateItem(id, { targets: Object.assign({}, it.targets, { [target]: 'posted' }) });
+ results.push({ id, target, status: r.status || (r.gated ? 'gated' : 'error'), gated: !!r.gated, reason: r.reason || null });
+ await new Promise((rs) => setTimeout(rs, delayMs));
+ }
+ const posted = results.filter((r) => r.status === 'posted').length;
+ const gated = results.filter((r) => r.gated).length;
+ res.json({ ok: true, attempted: results.length, posted, gated, skipped: results.length - posted - gated, delayMs, results });
+});
+
// Optional: set a resolved seller/brand email address on an item (for seller_email).
router.post('/seller-email-to', (req, res) => {
const { id, to } = req.body || {};
diff --git a/public/index.html b/public/index.html
index 68226b3c..48784fb4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -421,6 +421,24 @@
</div>
<div id="spend-ctl"></div>
<div id="spendgrid" style="max-height:640px;overflow:auto"></div>
+
+ <!-- BATCH REVIEW LENS (added, not a replacement for the card grid) -->
+ <div style="margin-top:12px;border-top:1px solid var(--line,#242a35);padding-top:10px">
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
+ <button type="button" id="spend-batch-toggle">▤ Batch review</button>
+ <span class="muted" style="font-size:11px">review every draft on one screen · select · approve in one pass · posting stays per-item & paced</span>
+ </div>
+ <div id="spend-batch" style="display:none;margin-top:10px">
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
+ <label style="font-size:12px"><input type="checkbox" id="spend-batch-all"> select all</label>
+ <select id="spend-batch-filter" style="font-size:12px"><option value="draftable">Needs approval</option><option value="approved">Approved (ready to post)</option><option value="all">All draftable</option></select>
+ <button type="button" id="spend-batch-approve">✔ Approve selected</button>
+ <button type="button" id="spend-batch-post">📤 Post approved (paced, gated)</button>
+ <span class="muted" id="spend-batch-note" style="font-size:11px"></span>
+ </div>
+ <div id="spend-batch-list" style="max-height:560px;overflow:auto;display:grid;gap:8px"></div>
+ </div>
+ </div>
</section>
<!-- AI CHAT -->
@@ -1242,6 +1260,7 @@ async function loadSpendReviews(){
}
let j; try{ j=await (await fetch('/api/spend/items')).json(); }catch(e){ return; }
const items=(j&&j.items)||[]; feedGrid('spend',items);
+ _spendItems=items; if(spendBatchOpen) renderSpendBatch();
const s=(j&&j.summary)||{};
const note=$('spend-note'); if(note) note.textContent=`${s.total||0} items · ${s.drafted||0} drafted · ${s.approved||0} approved · ${s.posted||0} posted · Google resolved ${s.resolvedGoogle||0}/${(s.resolvedGoogle||0)+(s.unresolvedGoogle||0)}`;
}
@@ -1271,6 +1290,77 @@ document.addEventListener('click',(e)=>{ const b=e.target.closest('[data-ingest]
const cf=$('spend-csv'); if(cf) cf.addEventListener('change',async()=>{ const f=cf.files&&cf.files[0]; if(!f) return; const csv=await f.text(); const n=$('spend-note'); if(n)n.textContent='parsing '+f.name+'…'; const j=await spendPost('upload-csv',{csv,filename:f.name}); if(n)n.textContent=(j.meta&&j.meta.note)||'uploaded'; await loadSpendReviews(); cf.value=''; });
})();
+// ---- BATCH REVIEW LENS (TK-11433) — one screen, select, approve-in-one-pass;
+// posting stays per-item, paced, gated. NO auto-approve, NO single-call bulk fire.
+let _spendItems=[], spendBatchOpen=false;
+function spendDraftableTargets(it){
+ if(it.excluded) return [];
+ return SPEND_TARGETS.filter(([t])=>it.drafts&&it.drafts[t]&&['draft','approved'].includes((it.targets&&it.targets[t])||'none'));
+}
+function spendBatchRows(){
+ const filt=($('spend-batch-filter')||{}).value||'draftable';
+ const rows=[];
+ for(const it of _spendItems){
+ for(const [t,label] of spendDraftableTargets(it)){
+ const st=(it.targets&&it.targets[t])||'none';
+ if(filt==='draftable'&&st!=='draft') continue;
+ if(filt==='approved'&&st!=='approved') continue;
+ rows.push({it,t,label,st,d:it.drafts[t]});
+ }
+ }
+ return rows;
+}
+function renderSpendBatch(){
+ const host=$('spend-batch-list'); if(!host) return;
+ // preserve the operator's selection across the 15s auto-refresh re-render
+ const wasChecked=new Set(Array.from(host.querySelectorAll('.spend-batch-cb:checked')).map(cb=>cb.getAttribute('data-id')+'|'+cb.getAttribute('data-target')));
+ const rows=spendBatchRows();
+ host.innerHTML=rows.map(({it,t,label,st,d})=>{
+ const body=t==='seller_email'
+ ? `<div class="dim" style="font-size:11px"><b>Subj:</b> ${esc(d.subject||'')}</div><div style="white-space:pre-wrap;font-size:12px">${esc(d.body||'')}</div>`
+ : `${d.title?`<div class="dim" style="font-size:11px"><b>${esc(d.title)}</b></div>`:''}<div style="font-size:12px">${esc(d.text||'')}</div>`;
+ const ck=wasChecked.has(it.id+'|'+t)?' checked':'';
+ return `<label class="card2" style="display:grid;grid-template-columns:22px 1fr;gap:8px;border:1px solid var(--line,#242a35);border-radius:8px;padding:8px;cursor:pointer">
+ <input type="checkbox" class="spend-batch-cb"${ck} data-id="${it.id}" data-target="${t}" data-status="${st}" style="margin-top:3px">
+ <div>
+ <div style="display:flex;justify-content:space-between;gap:8px"><b>${esc(it.display_merchant||it.merchant||'?')}</b> <span>${spendStatusPill(st)}</span></div>
+ <div class="dim" style="font-size:11px">${label}${it.product?' · '+esc(it.product):''} · ★${d.rating||5} · ${d.costLabel||'$0 (local)'}</div>
+ ${body}
+ <div style="margin-top:3px">${whenChip(it.created_at)}</div>
+ </div></label>`;
+ }).join('')||'<span class="muted">nothing to review in this filter</span>';
+ const cbs=host.querySelectorAll('.spend-batch-cb');
+ const note=$('spend-batch-note'); if(note) note.textContent=`${rows.length} target(s) shown`;
+ const all=$('spend-batch-all'); if(all) all.checked=(cbs.length>0 && Array.from(cbs).every(cb=>cb.checked));
+ cbs.forEach(cb=>cb.addEventListener('change',()=>{ if(all) all.checked=(cbs.length>0 && Array.from(cbs).every(x=>x.checked)); }));
+}
+function spendBatchSelected(){ return Array.from(document.querySelectorAll('.spend-batch-cb:checked')).map(cb=>({id:cb.getAttribute('data-id'),target:cb.getAttribute('data-target'),status:cb.getAttribute('data-status')})); }
+(function wireSpendBatch(){
+ const tg=$('spend-batch-toggle'); if(tg) tg.addEventListener('click',()=>{ spendBatchOpen=!spendBatchOpen; const el=$('spend-batch'); if(el) el.style.display=spendBatchOpen?'block':'none'; if(spendBatchOpen) renderSpendBatch(); });
+ const fl=$('spend-batch-filter'); if(fl) fl.addEventListener('change',renderSpendBatch);
+ const all=$('spend-batch-all'); if(all) all.addEventListener('change',()=>{ document.querySelectorAll('.spend-batch-cb').forEach(cb=>cb.checked=all.checked); });
+ const ab=$('spend-batch-approve'); if(ab) ab.addEventListener('click',async()=>{
+ const sel=spendBatchSelected().filter(s=>s.status==='draft').map(({id,target})=>({id,target}));
+ if(!sel.length){ alert('Select one or more draft rows to approve.'); return; }
+ const n=$('spend-batch-note'); if(n) n.textContent='approving '+sel.length+'…';
+ const j=await spendPost('approve-batch',{items:sel});
+ if(n) n.textContent=`approved ${j.approved||0}${j.failed?(' · '+j.failed+' failed'):''}`;
+ await loadSpendReviews();
+ });
+ const pb=$('spend-batch-post'); if(pb) pb.addEventListener('click',async()=>{
+ const sel=spendBatchSelected().filter(s=>s.status==='approved').map(({id,target})=>({id,target}));
+ if(!sel.length){ alert('Select one or more APPROVED rows to post. Approve drafts first.'); return; }
+ if(!confirm('Post '+sel.length+' approved item(s)? Each fires per-item, paced, and still requires EXECUTORS_LIVE + the content-seal. Nothing sends if the executor is gated.')) return;
+ const n=$('spend-batch-note'); if(n) n.textContent='posting '+sel.length+' (paced)…';
+ const j=await spendPost('post-approved',{items:sel,delayMs:1500});
+ const r=j.results||[];
+ const gatedReason=(r.find(x=>x.gated)||{}).reason||'';
+ if(n) n.textContent=`attempted ${j.attempted||0} · posted ${j.posted||0} · gated ${j.gated||0} · skipped ${j.skipped||0}`;
+ if(j.gated){ alert('Posted '+(j.posted||0)+' · GATED '+(j.gated||0)+' (nothing sent for those).\n\n'+gatedReason+'\n\nExecutors stay off until Steve sets SPEND_REVIEW_EXECUTORS_LIVE=1.'); }
+ await loadSpendReviews();
+ });
+})();
+
async function tick(){ if(document.hidden) return; await load(); loadWpb(); loadSpendReviews(); if(!_booted){ _booted=true; playEntrance(); } }
tick(); setInterval(tick, 15000);
</script>
← 7591ce1b auto-data-snapshot: 2026-09-11T08:16:47 (1 data files) — dat
·
back to AbramsEgo
·
auto-data-snapshot: 2026-09-11T08:57:34 (1 data files) — dat bb9a3a30 →