← back to Marketing Command Center
public/panels/engine.js
452 lines
// Engine panel — daily AI-suggested social posts grouped BY PRODUCT, with a
// per-channel approve → schedule → post flow, a header status strip, and a
// last-7-days pipeline strip. Registers into the shell's MCC_PANELS map (app.js).
// Dependency-free vanilla JS; every fetch is wrapped and API errors render an
// inline banner (never a blank panel). Auto-refreshes /status + /today (cheap,
// non-generating) every 30s and pauses when the tab is hidden.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['engine'] = {
init(root) {
const ORIGIN = location.origin;
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const CHAN = {
facebook: { icon: '📘', label: 'Facebook' },
instagram: { icon: '📷', label: 'Instagram' },
tiktok: { icon: '🎵', label: 'TikTok' },
youtube: { icon: '▶️', label: 'YouTube' },
linkedin: { icon: '💼', label: 'LinkedIn' },
bluesky: { icon: '🦋', label: 'Bluesky' },
threads: { icon: '🧵', label: 'Threads' },
};
const chanIcon = c => (CHAN[c] && CHAN[c].icon) || '•';
const chanLabel = c => (CHAN[c] && CHAN[c].label) || c;
// Local-day + local-time formatting (admin's timezone).
const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
const fmtTime = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); };
const pad = n => String(n).padStart(2, '0');
function todayISO() { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
// datetime-local value (no seconds, no offset) for a given day + HH:MM.
function slotLocalValue(dayISO, hhmm) {
const [h, m] = String(hhmm || '09:00').split(':');
return `${dayISO}T${pad(+h || 9)}:${pad(+m || 0)}`;
}
// Convert a datetime-local string back to a full ISO w/ the browser offset.
function localToISO(v) { if (!v) return null; const d = new Date(v); return isNaN(d) ? null : d.toISOString(); }
// ── wrapped fetch helpers ─────────────────────────────────────────────────
async function jget(url) {
const r = await fetch(ORIGIN + url, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
return r.json();
}
async function jpost(url, body) {
const r = await fetch(ORIGIN + url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', body: JSON.stringify(body || {}),
});
let j = {}; try { j = await r.json(); } catch {}
if (!r.ok && !j.error) j.error = `HTTP ${r.status}`;
return j;
}
function banner(msg, ok) {
const el = $('#eng-banner'); if (!el) return;
if (!msg) { el.hidden = true; el.textContent = ''; return; }
el.hidden = false; el.textContent = msg;
el.className = 'eng-banner' + (ok ? ' ok' : '');
}
// ── state ─────────────────────────────────────────────────────────────────
const state = {
status: null,
items: [], // today's items
active: {}, // productKey → active channel id
editing: {}, // itemId → true while its textarea is open
generating: false,
};
const productKey = it => (it.product && (it.product.dwSku || it.product.shopifyId || it.product.handle || it.product.title)) || it.id;
// ── 1 · header ────────────────────────────────────────────────────────────
function renderHeader() {
const s = state.status || {};
const on = !!s.scheduler;
const dot = $('#eng-sched-dot'); if (dot) dot.className = 'dot ' + (on ? 'ok' : 'bad');
const txt = $('#eng-sched-txt'); if (txt) txt.textContent = on ? 'Scheduler on' : 'Scheduler off';
const tick = $('#eng-tick');
if (tick) tick.textContent = s.lastTick ? `last tick ${fmtTime(s.lastTick)}` : (on ? 'no tick yet' : '');
const c = s.counts || {};
const chips = [
{ k: 'suggested', label: 'Suggested', n: c.suggested || 0, cls: '' },
{ k: 'approved', label: 'Scheduled', n: c.approved || 0, cls: 'blue' },
{ k: 'posted', label: 'Posted', n: c.posted || 0, cls: 'on' },
{ k: 'failed', label: 'Failed', n: c.failed || 0, cls: (c.failed ? 'warn' : '') },
];
if (c.gated) chips.push({ k: 'gated', label: 'Gated', n: c.gated, cls: 'warn' });
const chipsEl = $('#eng-chips');
if (chipsEl) chipsEl.innerHTML = chips.map(ch =>
`<span class="pill ${ch.cls}" title="${esc(ch.label)} today">${esc(ch.label)} ${ch.n}</span>`).join('');
// Cost badge — template if any visible item was written by the template engine.
const cost = $('#eng-cost');
if (cost) {
const anyTemplate = state.items.some(it => it.source && it.source.llm === 'template');
if (anyTemplate) {
cost.className = 'pill eng-cost template';
cost.textContent = '$0 template';
cost.title = 'captions written by the built-in template engine — no API cost';
} else {
cost.className = 'pill eng-cost';
cost.textContent = '$0 local';
cost.title = 'captions generated by local qwen3:14b — no API cost';
}
}
}
// ── 2 · today's suggestions (grouped by product) ──────────────────────────
function renderToday() {
const box = $('#eng-today');
const cnt = $('#eng-today-count');
if (!box) return;
if (state.generating) {
box.innerHTML = `<div class="eng-empty">
<div class="loading" style="padding:8px">Generating today's suggestions ($0 local model)…</div>
<div class="muted" style="font-size:12px">The local qwen3:14b caption pass can take 1–3 minutes on a cold start. This won't cost anything.</div>
</div>`;
if (cnt) cnt.textContent = '';
return;
}
const items = state.items.slice();
if (!items.length) {
box.innerHTML = `<div class="eng-empty">
No suggestions yet for today.
<div><button class="btn gold" id="eng-empty-gen">⚡ Generate today's suggestions</button></div>
</div>`;
const b = $('#eng-empty-gen'); if (b) b.onclick = doGenerate;
if (cnt) cnt.textContent = '';
return;
}
// group by product, preserving first-seen order.
const groups = new Map();
for (const it of items) {
const k = productKey(it);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(it);
}
if (cnt) cnt.textContent = `${groups.size} product${groups.size === 1 ? '' : 's'} · ${items.length} suggestion${items.length === 1 ? '' : 's'}`;
box.innerHTML = [...groups.entries()].map(([k, its]) => productCardHTML(k, its)).join('');
// wire each product card
for (const [k, its] of groups.entries()) wireProductCard(box, k, its);
}
function productCardHTML(key, its) {
const p = (its[0] && its[0].product) || {};
const img = p.imageUrl
? `<a href="${esc(p.productUrl || '#')}" target="_blank" rel="noopener noreferrer"><img src="${esc(p.imageUrl)}" alt="${esc(p.title || '')}" loading="lazy"></a>`
: `<div class="ph">🖼️</div>`;
const created = its.map(i => i.createdAt).filter(Boolean).sort()[0];
const meta = [p.vendor, p.productType].filter(Boolean).map(esc).join(' · ');
const titleInner = p.productUrl
? `<a href="${esc(p.productUrl)}" target="_blank" rel="noopener noreferrer">${esc(p.title || 'Untitled product')}</a>`
: esc(p.title || 'Untitled product');
// active channel default = first item's channel unless already chosen
if (!state.active[key]) state.active[key] = its[0].channel;
const active = state.active[key];
const tabs = its.map(it => {
const st = it.status || 'suggested';
const activeCls = it.channel === active ? ' active' : '';
const stLabel = st === 'approved' ? (it.scheduledAt ? fmtTime(it.scheduledAt) : 'sched')
: st === 'posted' ? '✓'
: st === 'failed' ? '✗'
: st === 'posting' ? '…'
: st === 'gated' ? '⛔'
: st === 'skipped' ? '—' : '';
const title = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : (st === 'approved' && it.scheduledAt ? ` title="scheduled ${esc(fmtWhen(it.scheduledAt))}"` : '');
return `<button class="eng-tab s-${esc(st)}${activeCls}" data-chan="${esc(it.channel)}"${title}>
<span class="ic">${chanIcon(it.channel)}</span>${chanLabel(it.channel)}
${stLabel ? `<span class="st">${esc(stLabel)}</span>` : ''}
</button>`;
}).join('');
return `<div class="eng-prod" data-key="${esc(key)}">
<div class="thumb">${img}</div>
<div class="body">
<div class="ptitle">${titleInner}</div>
<div class="pmeta">${meta || ' '}${p.dwSku ? ` · <span class="muted">${esc(p.dwSku)}</span>` : ''}</div>
<div class="pwhen" title="${esc(created || '')}">🕓 ${esc(fmtWhen(created))}</div>
<div class="eng-tabs">${tabs}</div>
<div class="eng-detail" data-detail></div>
</div>
</div>`;
}
function wireProductCard(box, key, its) {
const card = box.querySelector(`.eng-prod[data-key="${cssEsc(key)}"]`);
if (!card) return;
const byChan = Object.fromEntries(its.map(it => [it.channel, it]));
card.querySelectorAll('.eng-tab').forEach(tab => {
tab.onclick = () => {
state.active[key] = tab.dataset.chan;
card.querySelectorAll('.eng-tab').forEach(t => t.classList.toggle('active', t === tab));
renderDetail(card, byChan[tab.dataset.chan]);
};
});
renderDetail(card, byChan[state.active[key]]);
}
// escape a value for use in a [data-key="…"] attribute selector
function cssEsc(v) {
if (window.CSS && CSS.escape) return CSS.escape(String(v));
return String(v).replace(/["\\\]]/g, '\\$&');
}
function renderDetail(card, it) {
const box = card.querySelector('[data-detail]');
if (!box || !it) { if (box) box.innerHTML = ''; return; }
const st = it.status || 'suggested';
const editing = !!state.editing[it.id];
// gated → gate reason + Arm
if (st === 'gated') {
box.innerHTML = `
<div class="gate">⛔ Held: ${esc(it.gateReason || 'channel not available for live posting yet')}</div>
<div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(caption suppressed until armed)')}</div>
<div class="eng-actions"><button class="btn ghost" data-act="arm">Arm →</button><span class="msg"></span></div>`;
wireActions(box, card, it);
return;
}
const tags = (it.hashtags || []).slice(0, 12).map(t => `<span class="pill">${esc(t.replace(/^#*/, '#'))}</span>`).join('');
const capBlock = editing
? `<textarea data-cap>${esc(it.caption || '')}</textarea>`
: `<div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(no caption)')}</div>`;
// scheduled/posted read-outs
let statusLine = '';
if (st === 'approved' && it.scheduledAt) statusLine = `<div class="slot">📅 Scheduled for <b>${esc(fmtWhen(it.scheduledAt))}</b></div>`;
else if (st === 'posted') statusLine = `<div class="slot">✅ Posted ${esc(fmtWhen(it.postedAt))}${it.outboxId ? ` · <span class="muted">outbox ${esc(it.outboxId)}</span>` : ''}</div>`;
else if (st === 'failed') statusLine = `<div class="slot" style="color:#9a4a4a">✗ Failed${it.lastError ? ` — ${esc(it.lastError)}` : ''}</div>`;
else if (st === 'skipped') statusLine = `<div class="slot muted">Skipped</div>`;
else statusLine = `<div class="slot">Suggested slot <b>${esc(it.suggestedSlot || '—')}</b></div>`;
// actions available while suggested (and edit while suggested|approved)
let actions = '';
if (st === 'suggested') {
actions = `
<button class="btn gold" data-act="approve">Approve ▸ time</button>
<button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button>
<button class="btn ghost" data-act="skip">Skip</button>
<span class="msg"></span>`;
} else if (st === 'approved') {
actions = `<button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button><span class="msg"></span>`;
}
box.innerHTML = `
${capBlock}
<div class="tags">${tags}</div>
${statusLine}
<div class="eng-actions">${actions}</div>
<div class="eng-sched-inline" data-picker hidden>
<label>When</label>
<input type="datetime-local" data-when value="${esc(slotLocalValue(it.date || todayISO(), it.suggestedSlot))}">
${it.channel === 'youtube' ? `<label>Privacy</label>
<select data-privacy>
<option value="private">private (review first, promote later)</option>
<option value="unlisted">unlisted</option>
<option value="public">public</option>
</select>` : ''}
<button class="btn gold" data-act="approve-confirm">Confirm</button>
<button class="btn ghost" data-act="approve-cancel">Cancel</button>
</div>`;
wireActions(box, card, it);
}
function wireActions(box, card, it) {
const msg = box.querySelector('.msg');
const setMsg = t => { if (msg) msg.textContent = t; };
box.querySelectorAll('[data-act]').forEach(b => {
b.onclick = () => handleAction(b.dataset.act, it, box, card, setMsg);
});
}
async function handleAction(act, it, box, card, setMsg) {
if (act === 'edit') {
if (state.editing[it.id]) {
// save
const ta = box.querySelector('[data-cap]');
const caption = ta ? ta.value : it.caption;
setMsg('saving…');
const r = await jpost('/api/engine/edit', { id: it.id, caption });
if (r.error) { setMsg('✗ ' + r.error); return; }
state.editing[it.id] = false;
applyUpdated(r.item);
} else {
state.editing[it.id] = true;
renderDetail(card, it);
}
return;
}
if (act === 'skip') {
if (!confirm('Skip this suggestion? It won\'t be posted.')) return;
setMsg('skipping…');
const r = await jpost('/api/engine/skip', { id: it.id });
if (r.error) { setMsg('✗ ' + r.error); return; }
applyUpdated(r.item); refreshStatus();
return;
}
if (act === 'arm') {
setMsg('arming…');
const r = await jpost('/api/engine/arm', { id: it.id });
if (r.error) { setMsg('✗ ' + r.error); return; }
applyUpdated(r.item); refreshStatus();
return;
}
if (act === 'approve') {
// open the inline picker
const picker = box.querySelector('[data-picker]');
if (picker) picker.hidden = false;
return;
}
if (act === 'approve-cancel') {
const picker = box.querySelector('[data-picker]');
if (picker) picker.hidden = true;
return;
}
if (act === 'approve-confirm') {
const whenEl = box.querySelector('[data-when]');
const privEl = box.querySelector('[data-privacy]');
const scheduledAt = whenEl ? localToISO(whenEl.value) : null;
const body = { id: it.id };
if (scheduledAt) body.scheduledAt = scheduledAt;
if (privEl) body.privacy = privEl.value;
setMsg('approving…');
const r = await jpost('/api/engine/approve', body);
if (r.error) { setMsg('✗ ' + r.error); return; }
applyUpdated(r.item); refreshStatus();
return;
}
}
// Merge one updated item back into state.items and re-render just the affected
// product card (cheap; keeps the active-tab selection).
function applyUpdated(item) {
if (!item) return;
const i = state.items.findIndex(x => x.id === item.id);
if (i >= 0) state.items[i] = { ...state.items[i], ...item };
else state.items.push(item);
renderToday();
}
// ── 3 · pipeline strip (last 7 days) ──────────────────────────────────────
async function loadPipeline() {
const box = $('#eng-pipeline'); const cnt = $('#eng-pipe-count');
try {
const d = await jget('/api/engine/queue?days=7');
const items = (d.items || []);
if (cnt) cnt.textContent = `${items.length} item${items.length === 1 ? '' : 's'}`;
if (!box) return;
if (!items.length) { box.innerHTML = '<div class="muted">No activity in the last 7 days.</div>'; return; }
box.innerHTML = items.map(it => {
const p = it.product || {};
const thumb = (it.mediaUrl || p.imageUrl)
? `<img class="pt" src="${esc(it.mediaUrl || p.imageUrl)}" alt="" loading="lazy">`
: `<div class="pt ph">🖼️</div>`;
const st = it.status || 'suggested';
const tm = it.postedAt ? fmtWhen(it.postedAt) : it.scheduledAt ? fmtWhen(it.scheduledAt) : (it.date || '');
const errTitle = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : '';
return `<div class="eng-pc"${errTitle}>
${thumb}
<div class="meta">
<div class="ch">${chanIcon(it.channel)} ${chanLabel(it.channel)}</div>
<div class="st s-${esc(st)}">${esc(st)}${st === 'failed' ? ' ✗' : st === 'posted' ? ' ✓' : ''}</div>
<div class="tm">${esc(tm)}</div>
</div>
</div>`;
}).join('');
} catch (e) {
if (cnt) cnt.textContent = '';
if (box) box.innerHTML = `<div class="muted">Couldn't load history: ${esc(e.message)}</div>`;
}
}
// ── loaders ───────────────────────────────────────────────────────────────
async function refreshStatus() {
try { state.status = await jget('/api/engine/status'); banner(''); }
catch (e) { banner('Engine status unavailable: ' + e.message); }
renderHeader();
}
// Cheap, non-generating today peek (used on load + on the 30s poll).
async function refreshToday() {
try {
const d = await jget('/api/engine/today?generate=0');
state.items = d.items || [];
banner('');
} catch (e) {
banner('Could not load today\'s suggestions: ' + e.message);
}
renderToday();
renderHeader(); // cost badge depends on items
}
// Force a fresh generation (may take 1–3 min via the local model).
async function doGenerate() {
if (state.generating) return;
state.generating = true;
renderToday();
banner('Generating today\'s suggestions with the local model — this can take 1–3 minutes…', true);
const genBtn = $('#eng-generate'); if (genBtn) { genBtn.disabled = true; genBtn.textContent = 'Generating…'; }
const r = await jpost('/api/engine/generate', {});
state.generating = false;
if (genBtn) { genBtn.disabled = false; genBtn.textContent = '⚡ Generate today'; }
if (r.error) { banner('Generation failed: ' + r.error); renderToday(); return; }
state.items = r.items || [];
if (r.note && !state.items.length) banner(r.note);
else banner('Suggestions ready.', true);
renderToday();
await refreshStatus();
await loadPipeline();
}
// ── auto-refresh (30s), paused when the tab is hidden ─────────────────────
let timer = null;
function tick() {
if (document.hidden || state.generating) return;
// don't stomp an open editor
if (Object.values(state.editing).some(Boolean)) { refreshStatus(); return; }
refreshStatus();
refreshToday();
loadPipeline();
}
function startTimer() { stopTimer(); timer = setInterval(tick, 30000); }
function stopTimer() { if (timer) { clearInterval(timer); timer = null; } }
const onVis = () => { if (!document.hidden) tick(); };
document.addEventListener('visibilitychange', onVis);
// stop polling once this panel is swapped out (its root leaves the DOM).
const mo = new MutationObserver(() => {
if (!document.body.contains(root)) { stopTimer(); document.removeEventListener('visibilitychange', onVis); mo.disconnect(); }
});
try { mo.observe(document.getElementById('panel') || document.body, { childList: true }); } catch {}
// ── wire header button + boot ─────────────────────────────────────────────
const gen = $('#eng-generate'); if (gen) gen.onclick = doGenerate;
(async function boot() {
await refreshStatus();
await refreshToday();
await loadPipeline();
startTimer();
})();
},
};