← back to Marketing Command Center

public/panels/follow-counts.js

166 lines

// Followers / Following panel — per-account followers_count + follows_count cards
// (COUNTS ONLY, official Graph API via Norma :9810) + a daily-snapshot SVG growth
// chart. Labels "awaiting live IG creds" when Norma is in simulation mode rather
// than presenting simulated numbers as live.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['follow-counts'] = {
  init(root) {
    const $ = s => root.querySelector(s);
    const api = (p, opts) => fetch(`/api/follow-counts${p}`, opts).then(r => r.json());
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const fmtN = n => (n == null ? '—' : Number(n).toLocaleString());
    const fmtDelta = d => d == null ? '' :
      (d > 0 ? `<span style="color:#1a7f4b">▲ ${fmtN(d)}</span>`
        : d < 0 ? `<span style="color:#b3261e">▼ ${fmtN(Math.abs(d))}</span>`
          : `<span class="muted">no change</span>`);
    const fmtTime = t => t ? new Date(t).toLocaleString(undefined,
      { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';

    let selectedHandle = null;
    let chartMetric = 'followers';

    function banner(awaiting, source) {
      $('#fc-banner').innerHTML = awaiting
        ? `<div class="muted-banner"><b>Awaiting live IG creds.</b> Norma's instagram-agent is in
             simulation mode (IG_USER_ID / IG_ACCESS_TOKEN not configured). The panel is wired to the
             real Graph API shape (<code>followers_count</code> + <code>follows_count</code>) and is
             showing simulated counts — they will switch to live numbers the moment Norma's IG
             credentials are set. ${source ? `<br><span class="muted" style="font-size:11px">Source: ${esc(source)}</span>` : ''}</div>`
        : '';
    }

    async function loadAccounts() {
      const cards = $('#fc-cards');
      cards.innerHTML = '<div class="loading">Loading accounts…</div>';
      let d;
      try { d = await api('/accounts'); }
      catch (e) { cards.innerHTML = `<div class="muted-banner">Failed to load: ${esc(e.message)}</div>`; return; }
      banner(d.awaitingCreds, d.source);
      const accts = d.accounts || [];
      if (!accts.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
      cards.innerHTML = accts.map(a => {
        const l = a.latest;
        const simBadge = (!l || l.simulated)
          ? `<span class="pill" style="background:#fff3cd;color:#7a5b00">awaiting creds</span>`
          : `<span class="pill" style="background:#e6f4ea;color:#1a7f4b">live</span>`;
        return `<div class="card fc-acct" data-handle="${esc(a.handle)}" style="cursor:pointer">
          <div class="row" style="justify-content:space-between;align-items:flex-start">
            <div>
              <h2 style="margin:0">@${esc(a.handle)}</h2>
              <div class="muted" style="font-size:12px">${esc(a.label)}${a.primary ? ' · primary' : ''}</div>
            </div>${simBadge}
          </div>
          <div class="row" style="gap:24px;margin-top:14px">
            <div>
              <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Followers</div>
              <div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.followers)}</div>
              <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.followers) : '<span class="muted">—</span>'}</div>
            </div>
            <div>
              <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Following</div>
              <div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.following)}</div>
              <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.following) : '<span class="muted">—</span>'}</div>
            </div>
          </div>
          <div class="muted" style="font-size:11px;margin-top:12px"
               title="${l ? esc(l.ts) : ''}">
            ${l ? `Last snapshot: ${fmtTime(l.ts)} · ${a.points} day${a.points === 1 ? '' : 's'} of history` : 'No snapshot yet — click “Capture snapshot”.'}
          </div>
          <div class="muted" style="font-size:11px;margin-top:2px">Click to chart growth →</div>
        </div>`;
      }).join('');
      cards.querySelectorAll('.fc-acct').forEach(c =>
        c.onclick = () => selectAccount(c.dataset.handle));
      // auto-select the first (primary) account for the chart
      if (!selectedHandle && accts[0]) selectAccount(accts[0].handle);
      else if (selectedHandle) renderChart();
    }

    async function selectAccount(handle) {
      selectedHandle = handle;
      root.querySelectorAll('.fc-acct').forEach(c =>
        c.style.outline = c.dataset.handle === handle ? '2px solid #c9a14a' : 'none');
      await renderChart();
    }

    async function renderChart() {
      if (!selectedHandle) return;
      const card = $('#fc-chart-card'); card.style.display = '';
      $('#fc-chart-title').textContent = `Growth over time · @${selectedHandle}`;
      root.querySelectorAll('.fc-metric').forEach(b =>
        b.classList.toggle('gold', b.dataset.metric === chartMetric));
      const box = $('#fc-chart');
      box.innerHTML = '<div class="loading">Loading history…</div>';
      let d;
      try { d = await api(`/history?handle=${encodeURIComponent(selectedHandle)}`); }
      catch (e) { box.innerHTML = `<div class="muted-banner">Failed: ${esc(e.message)}</div>`; return; }
      const series = (d.series || []).filter(p => p[chartMetric] != null);
      $('#fc-chart-sub').textContent = `${series.length} daily point${series.length === 1 ? '' : 's'} · metric: ${chartMetric}`;
      if (series.length < 2) {
        box.innerHTML = `<div class="muted-banner">Need at least 2 daily snapshots to chart a trend.
          ${series.length === 1 ? 'One captured so far — history accrues once per day (a launchd job can automate this).' : 'Click “Capture snapshot” to start.'}</div>`;
        $('#fc-chart-foot').textContent = '';
        return;
      }
      box.innerHTML = svgLineChart(series, chartMetric);
      const first = series[0], last = series[series.length - 1];
      const net = (last[chartMetric] - first[chartMetric]);
      $('#fc-chart-foot').innerHTML =
        `Net ${chartMetric} change over ${series.length} days: ${fmtDelta(net)} · ` +
        `${first.date} → ${last.date}` +
        (series.some(p => p.simulated) ? ' · <b>includes simulated points (awaiting live creds)</b>' : '');
    }

    // Minimal dependency-free SVG line chart.
    function svgLineChart(series, metric) {
      const W = 640, H = 220, pad = { l: 52, r: 16, t: 14, b: 28 };
      const xs = series.map((_, i) => i);
      const ys = series.map(p => p[metric]);
      const minY = Math.min(...ys), maxY = Math.max(...ys);
      const spanY = (maxY - minY) || 1;
      const px = i => pad.l + (i / (series.length - 1)) * (W - pad.l - pad.r);
      const py = v => pad.t + (1 - (v - minY) / spanY) * (H - pad.t - pad.b);
      const pts = series.map((p, i) => `${px(i)},${py(p[metric])}`).join(' ');
      // y gridlines (4)
      let grid = '';
      for (let k = 0; k <= 4; k++) {
        const v = minY + (spanY * k / 4), y = py(v);
        grid += `<line x1="${pad.l}" y1="${y}" x2="${W - pad.r}" y2="${y}" stroke="#eee" />` +
          `<text x="${pad.l - 6}" y="${y + 3}" text-anchor="end" font-size="10" fill="#999">${Math.round(v).toLocaleString()}</text>`;
      }
      const dots = series.map((p, i) =>
        `<circle cx="${px(i)}" cy="${py(p[metric])}" r="3" fill="${p.simulated ? '#c9a14a' : '#1a7f4b'}">` +
        `<title>${esc(p.date)}: ${Number(p[metric]).toLocaleString()}${p.simulated ? ' (simulated)' : ''}</title></circle>`).join('');
      // x labels: first, middle, last
      const idxs = [0, Math.floor((series.length - 1) / 2), series.length - 1];
      const xlab = idxs.map(i =>
        `<text x="${px(i)}" y="${H - 8}" text-anchor="middle" font-size="10" fill="#999">${esc(series[i].date.slice(5))}</text>`).join('');
      return `<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-width:680px" role="img" aria-label="growth chart">
        ${grid}
        <polyline fill="none" stroke="#c9a14a" stroke-width="2" points="${pts}" />
        ${dots}${xlab}
      </svg>`;
    }

    async function captureSnapshot() {
      const btn = $('#fc-snapshot'); const prev = btn.textContent;
      btn.textContent = 'Capturing…'; btn.disabled = true;
      try {
        const r = await api('/snapshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
        if (r.ok === false && r.error) alert('Snapshot note: ' + r.error);
      } catch (e) { alert('Snapshot failed: ' + e.message); }
      btn.textContent = prev; btn.disabled = false;
      await loadAccounts();
    }

    // ── wire controls ────────────────────────────────────────────────────────
    $('#fc-refresh').onclick = loadAccounts;
    $('#fc-snapshot').onclick = captureSnapshot;
    root.querySelectorAll('.fc-metric').forEach(b =>
      b.onclick = () => { chartMetric = b.dataset.metric; renderChart(); });

    loadAccounts();
  },
};