← 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 =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 || '&nbsp;'}${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();
    })();
  },
};