← 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 =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
},
};