← back to Marketing Command Center
Add Followers/Following tab to DW Marketing Command Center
90978c982234032d72f851a8d3d86f312af7f41b · 2026-06-24 12:08:43 -0700 · Steve Abrams
New follow-counts module: per-DW-IG-account followers_count + follows_count
(COUNTS ONLY — official Instagram Graph API returns no username lists) with a
once-daily snapshot store for growth-over-time charting.
- modules/follow-counts/index.js: /accounts, /history, /snapshot, /roster.
Pulls counts via Norma instagram-agent (:9810) — reuses its IG token/IG_USER_ID,
honors simulation mode (labels 'awaiting live IG creds', never fakes live).
Daily-idempotent JSON snapshot store (data/follow-counts-history.json).
- public/panels/follow-counts.{html,js}: per-account count cards + delta +
dependency-free SVG growth chart (followers/following toggle).
- registry.js + app.js: mounted under Analytics nav group.
- scripts/follow-counts-snapshot.js + deploy/com.steve.dw-follow-counts.plist:
STAGED once-daily launchd capture (not loaded — gated on Steve).
- .env.example: NORMA_IG_* override docs + go-live note.
Surface chosen by DTD (3/3 unanimous): marketing-command-center.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .env.exampleM .gitignoreA deploy/com.steve.dw-follow-counts.plistA modules/follow-counts/index.jsM modules/registry.jsM public/app.jsA public/panels/follow-counts.htmlA public/panels/follow-counts.jsA scripts/follow-counts-snapshot.js
Diff
commit 90978c982234032d72f851a8d3d86f312af7f41b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 24 12:08:43 2026 -0700
Add Followers/Following tab to DW Marketing Command Center
New follow-counts module: per-DW-IG-account followers_count + follows_count
(COUNTS ONLY — official Instagram Graph API returns no username lists) with a
once-daily snapshot store for growth-over-time charting.
- modules/follow-counts/index.js: /accounts, /history, /snapshot, /roster.
Pulls counts via Norma instagram-agent (:9810) — reuses its IG token/IG_USER_ID,
honors simulation mode (labels 'awaiting live IG creds', never fakes live).
Daily-idempotent JSON snapshot store (data/follow-counts-history.json).
- public/panels/follow-counts.{html,js}: per-account count cards + delta +
dependency-free SVG growth chart (followers/following toggle).
- registry.js + app.js: mounted under Analytics nav group.
- scripts/follow-counts-snapshot.js + deploy/com.steve.dw-follow-counts.plist:
STAGED once-daily launchd capture (not loaded — gated on Steve).
- .env.example: NORMA_IG_* override docs + go-live note.
Surface chosen by DTD (3/3 unanimous): marketing-command-center.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.env.example | 10 ++
.gitignore | 8 ++
deploy/com.steve.dw-follow-counts.plist | 39 +++++++
modules/follow-counts/index.js | 183 ++++++++++++++++++++++++++++++++
modules/registry.js | 1 +
public/app.js | 2 +-
public/panels/follow-counts.html | 38 +++++++
public/panels/follow-counts.js | 165 ++++++++++++++++++++++++++++
scripts/follow-counts-snapshot.js | 37 +++++++
9 files changed, 482 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index c7c23c4..1fc07b5 100644
--- a/.env.example
+++ b/.env.example
@@ -36,3 +36,13 @@ META_GRAPH_VERSION=
# Set to 'true' ONLY when you want the scheduler to auto-post due+approved posts.
# Leave unset/false to keep the scheduler in dry mode (logs, never sends).
SOCIAL_SCHEDULER_LIVE=
+# ── Followers / Following tab ────────────────────────────────────────────────
+# The follow-counts module pulls each DW IG account's followers_count +
+# follows_count (COUNTS ONLY — the Graph API returns no username lists) by calling
+# Norma's instagram-agent, which owns the IG token + IG_USER_ID. No new token here.
+# These only need setting if Norma isn't at the default localhost address/cred.
+NORMA_IG_BASE=http://127.0.0.1:9810
+NORMA_IG_USER=admin
+NORMA_IG_PASS=
+# To go LIVE (currently simulated): configure IG_USER_ID + IG_ACCESS_TOKEN on
+# Norma's instagram-agent (NOT here) — this tab then shows live counts automatically.
diff --git a/.gitignore b/.gitignore
index 501957d..003d749 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,14 @@ data/linkedin-drafts.json
data/ctct-store.json
data/reposts.json
+# Followers/Following runtime state — daily snapshots + roster accrue per box; the
+# snapshot log (.out/.err) is launchd-run state. Code (module + panel) is tracked,
+# the captured history is not (so deploys don't clobber accrued growth data).
+data/follow-counts-history.json
+data/follow-counts-accounts.json
+data/follow-counts-snapshot.out
+data/follow-counts-snapshot.err
+
# Meta go-live creds — real Page token, never commit
scripts/meta-creds.env
data/meta-pages.json
diff --git a/deploy/com.steve.dw-follow-counts.plist b/deploy/com.steve.dw-follow-counts.plist
new file mode 100644
index 0000000..9efdd02
--- /dev/null
+++ b/deploy/com.steve.dw-follow-counts.plist
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ STAGED — NOT loaded. Once-daily Followers/Following snapshot for the DW Marketing
+ Command Center. Captures each DW IG account's followers_count + follows_count via
+ Norma's instagram-agent (:9810) and persists one row/day so growth charts.
+
+ Local install (Mac2), when Steve approves:
+ cp deploy/com.steve.dw-follow-counts.plist ~/Library/LaunchAgents/
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.dw-follow-counts.plist
+ Verify: launchctl list | grep dw-follow-counts
+ Run now: launchctl kickstart gui/$(id -u)/com.steve.dw-follow-counts
+
+ Runs daily at 06:05 local (RunAtLoad so a missed forced-sleep day still fires on
+ next login — see MEMORY: keepawake does not block power-button sleep).
+-->
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.dw-follow-counts</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/usr/local/bin/node</string>
+ <string>/Users/stevestudio2/Projects/marketing-command-center/scripts/follow-counts-snapshot.js</string>
+ </array>
+ <key>WorkingDirectory</key>
+ <string>/Users/stevestudio2/Projects/marketing-command-center</string>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>StartCalendarInterval</key>
+ <dict>
+ <key>Hour</key><integer>6</integer>
+ <key>Minute</key><integer>5</integer>
+ </dict>
+ <key>StandardOutPath</key>
+ <string>/Users/stevestudio2/Projects/marketing-command-center/data/follow-counts-snapshot.out</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/stevestudio2/Projects/marketing-command-center/data/follow-counts-snapshot.err</string>
+</dict>
+</plist>
diff --git a/modules/follow-counts/index.js b/modules/follow-counts/index.js
new file mode 100644
index 0000000..fe37270
--- /dev/null
+++ b/modules/follow-counts/index.js
@@ -0,0 +1,183 @@
+// Followers / Following — per-DW-Instagram-account follower_count + follows_count
+// (COUNTS ONLY, never username lists — the IG Graph API does not return follower/
+// following username lists, only the aggregate followers_count / follows_count),
+// with a once-daily snapshot so growth-over-time can be charted.
+//
+// DATA SOURCE (single source of truth): Norma's instagram-agent on :9810. Its
+// `monitor` skill already issues the canonical Graph call
+// GET https://graph.facebook.com/v23.0/{ig_user_id}
+// ?fields=followers_count,follows_count,media_count&access_token=TOKEN
+// and returns { account:{ followers_count, follows_count, media_count }, simulated }.
+// We REUSE that plumbing/token rather than mint a new one. Until Norma's
+// IG_USER_ID / IG_ACCESS_TOKEN are configured it returns simulated:true, in which
+// case the panel labels the data "awaiting live IG creds" — we persist the shape,
+// not fake live numbers.
+//
+// NO scraping, NO unofficial endpoints — official Graph API counts only.
+const fs = require('fs');
+const path = require('path');
+
+const DATA = path.join(__dirname, '..', '..', 'data');
+const ACCOUNTS = path.join(DATA, 'follow-counts-accounts.json');
+const HISTORY = path.join(DATA, 'follow-counts-history.json');
+
+// ── Norma instagram-agent (reuse its token/IG-user-id/simulation) ─────────────
+const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
+const NORMA_USER = process.env.NORMA_IG_USER || 'admin';
+const NORMA_PASS = process.env.NORMA_IG_PASS || ''; // empty by default (admin:)
+const normaAuth = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
+
+// ── account roster ────────────────────────────────────────────────────────────
+// The DW-owned IG accounts this tab tracks. Each: { handle, label, igUserId? }.
+// igUserId is optional — when Norma is wired to a single IG account it resolves
+// the id itself; the handle is the human label + history key. Seeded with DW's
+// primary account; Norma currently drives a single business account.
+const DEFAULT_ACCOUNTS = [
+ { handle: 'designerwallcoverings', label: 'Designer Wallcoverings', primary: true },
+];
+function loadAccounts() {
+ try { return JSON.parse(fs.readFileSync(ACCOUNTS, 'utf8')); }
+ catch { return DEFAULT_ACCOUNTS; }
+}
+function saveAccounts(a) {
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.writeFileSync(ACCOUNTS, JSON.stringify(a, null, 2));
+}
+
+// ── history store: { [handle]: [ {date,ts,followers,following,media,simulated} ] } ─
+function loadHistory() {
+ try { return JSON.parse(fs.readFileSync(HISTORY, 'utf8')); }
+ catch { return {}; }
+}
+function saveHistory(h) {
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.writeFileSync(HISTORY, JSON.stringify(h, null, 2));
+}
+const todayKey = (d = new Date()) => d.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
+
+// Ask Norma for the current account counts. Returns
+// { ok, followers, following, media, simulated, checked_at } (or { ok:false, error })
+async function fetchCountsFromNorma() {
+ const url = `${NORMA_BASE}/api/skill/monitor`;
+ try {
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
+ body: JSON.stringify({ period: 'day', push_to_pulse: false }),
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.success === false) {
+ return { ok: false, error: j.error || `Norma monitor HTTP ${r.status}` };
+ }
+ const acct = (j.result && j.result.account) || {};
+ return {
+ ok: true,
+ followers: acct.followers_count ?? null,
+ following: acct.follows_count ?? null,
+ media: acct.media_count ?? null,
+ simulated: !!(j.result && j.result.simulated),
+ checked_at: (j.result && j.result.checked_at) || new Date().toISOString(),
+ };
+ } catch (e) {
+ return { ok: false, error: `Norma unreachable: ${e.message}` };
+ }
+}
+
+// Capture a snapshot for every roster account. Idempotent per UTC day: a second
+// call the same day OVERWRITES that day's row (keeps the latest reading, never
+// duplicates the date) so growth points stay one-per-day.
+async function captureSnapshot() {
+ const accounts = loadAccounts();
+ const hist = loadHistory();
+ const date = todayKey();
+ const ts = new Date().toISOString();
+ const results = [];
+ // Norma currently drives a single IG account, so one monitor call covers the
+ // primary; if multiple accounts are configured the same reading applies to the
+ // primary only and others record a no-data point until Norma is multi-account.
+ const counts = await fetchCountsFromNorma();
+ for (const a of accounts) {
+ const h = a.handle;
+ hist[h] = hist[h] || [];
+ let row;
+ if (a.primary !== false && counts.ok) {
+ row = {
+ date, ts,
+ followers: counts.followers,
+ following: counts.following,
+ media: counts.media,
+ simulated: counts.simulated,
+ source: 'norma:monitor',
+ };
+ } else {
+ row = { date, ts, followers: null, following: null, media: null, simulated: true,
+ source: counts.ok ? 'no-account-binding' : 'norma-error',
+ error: counts.ok ? undefined : counts.error };
+ }
+ // upsert by date
+ const idx = hist[h].findIndex(r => r.date === date);
+ if (idx >= 0) hist[h][idx] = row; else hist[h].push(row);
+ hist[h].sort((x, y) => x.date.localeCompare(y.date));
+ results.push({ handle: h, ...row });
+ }
+ saveHistory(hist);
+ return { ok: counts.ok, simulated: counts.ok ? counts.simulated : true,
+ error: counts.ok ? undefined : counts.error, date, captured: results };
+}
+
+module.exports = {
+ id: 'follow-counts',
+ title: 'Followers / Following',
+ icon: '👥',
+ mount(router) {
+ // Roster + each account's latest snapshot + delta vs the previous snapshot.
+ router.get('/accounts', (_req, res) => {
+ const accounts = loadAccounts();
+ const hist = loadHistory();
+ const rows = accounts.map(a => {
+ const series = hist[a.handle] || [];
+ const last = series[series.length - 1] || null;
+ const prev = series.length > 1 ? series[series.length - 2] : null;
+ const delta = (last && prev && last.followers != null && prev.followers != null)
+ ? { followers: last.followers - prev.followers, following: (last.following ?? 0) - (prev.following ?? 0) }
+ : null;
+ return {
+ handle: a.handle, label: a.label || a.handle, primary: a.primary !== false,
+ latest: last, previous: prev, delta, points: series.length,
+ };
+ });
+ const anySim = rows.some(r => !r.latest || r.latest.simulated);
+ res.json({ accounts: rows, awaitingCreds: anySim,
+ source: 'instagram graph api · followers_count + follows_count (counts only) via norma :9810' });
+ });
+
+ // Daily series for one account, for the growth chart.
+ router.get('/history', (req, res) => {
+ const handle = String(req.query.handle || '').replace(/^@/, '').trim();
+ const hist = loadHistory();
+ res.json({ handle, series: hist[handle] || [] });
+ });
+
+ // Capture today's snapshot now (also the endpoint the daily launchd job hits).
+ router.post('/snapshot', async (_req, res) => {
+ try { res.json(await captureSnapshot()); }
+ catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+ });
+
+ // Manage the roster (add/remove a tracked DW account).
+ router.get('/roster', (_req, res) => res.json({ accounts: loadAccounts() }));
+ router.post('/roster', (req, res) => {
+ const { handle, label, primary } = req.body || {};
+ const h = String(handle || '').replace(/^@/, '').trim();
+ if (!h) return res.status(400).json({ ok: false, error: 'handle required' });
+ const accounts = loadAccounts();
+ if (accounts.some(a => a.handle === h)) return res.json({ ok: true, accounts, note: 'already present' });
+ accounts.push({ handle: h, label: label || h, primary: !!primary });
+ saveAccounts(accounts);
+ res.json({ ok: true, accounts });
+ });
+ },
+
+ // exported so a CLI / launchd wrapper can capture without going through HTTP
+ captureSnapshot,
+};
diff --git a/modules/registry.js b/modules/registry.js
index 8ad3999..2022f7b 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -15,6 +15,7 @@ module.exports = [
'assets', // image asset library: upload / URL / live DW-catalog pull
'performance', // performance dashboard: email + GA4 KPIs, charts, top campaigns
'social', // social scheduler: IG/TikTok queue board, gated publish
+ 'follow-counts', // Followers/Following: per-DW-IG-account followers_count + follows_count (COUNTS ONLY, official Graph API via Norma :9810) + daily-snapshot growth chart
'linkedin', // LinkedIn composer + drafts + manual post (deep-link) + gated API publish (Phase 2)
'segments', // audience segments: rule builder + live preview over contacts
'vendors', // vendor IG reporting: DW + all vendor Instagram accounts roster
diff --git a/public/app.js b/public/app.js
index 377b9eb..e9ca949 100644
--- a/public/app.js
+++ b/public/app.js
@@ -14,7 +14,7 @@ const GROUPS = [
{ name: 'Email', ids: ['constant-contact', 'send-times'] },
{ name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
{ name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
- { name: 'Analytics', ids: ['performance', 'ab-tests'] },
+ { name: 'Analytics', ids: ['performance', 'follow-counts', 'ab-tests'] },
{ name: 'Planning', ids: ['calendar', 'vendors'] },
];
diff --git a/public/panels/follow-counts.html b/public/panels/follow-counts.html
new file mode 100644
index 0000000..21645a0
--- /dev/null
+++ b/public/panels/follow-counts.html
@@ -0,0 +1,38 @@
+<div id="fc-banner"></div>
+
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
+ <div>
+ <h2>Followers / Following</h2>
+ <div class="muted" style="font-size:12px;max-width:640px">
+ Live <b>followers_count</b> + <b>follows_count</b> for each DW Instagram account, pulled
+ via the official Instagram Graph API (counts only — the Graph API does not return
+ follower/following username lists). A daily snapshot is persisted so growth-over-time
+ charts. Routed through Norma's instagram-agent (<span class="muted">:9810</span>) — no scraping.
+ </div>
+ </div>
+ <div class="row" style="gap:8px;align-items:center">
+ <button class="btn ghost" id="fc-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+ <button class="btn gold" id="fc-snapshot" style="padding:6px 12px;font-size:12.5px" title="Capture today's counts now">Capture snapshot</button>
+ </div>
+ </div>
+</div>
+
+<div id="fc-cards" class="grid" style="grid-template-columns:repeat(auto-fill,minmax(320px,1fr));align-items:start">
+ <div class="loading">Loading accounts…</div>
+</div>
+
+<div class="card" id="fc-chart-card" style="display:none">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <div>
+ <h2 id="fc-chart-title">Growth over time</h2>
+ <div class="muted" id="fc-chart-sub" style="font-size:12px">One point per day</div>
+ </div>
+ <div class="row" style="gap:6px">
+ <button class="btn ghost fc-metric" data-metric="followers" style="padding:5px 11px;font-size:12px">Followers</button>
+ <button class="btn ghost fc-metric" data-metric="following" style="padding:5px 11px;font-size:12px">Following</button>
+ </div>
+ </div>
+ <div id="fc-chart" style="margin-top:14px"></div>
+ <div class="muted" id="fc-chart-foot" style="font-size:11px;margin-top:8px"></div>
+</div>
diff --git a/public/panels/follow-counts.js b/public/panels/follow-counts.js
new file mode 100644
index 0000000..ce1b77f
--- /dev/null
+++ b/public/panels/follow-counts.js
@@ -0,0 +1,165 @@
+// 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();
+ },
+};
diff --git a/scripts/follow-counts-snapshot.js b/scripts/follow-counts-snapshot.js
new file mode 100644
index 0000000..c7010f9
--- /dev/null
+++ b/scripts/follow-counts-snapshot.js
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+// Once-daily Followers/Following snapshot capture for the DW Marketing Command
+// Center. Calls the follow-counts module's captureSnapshot() directly (no HTTP /
+// no auth needed — same process boundary as the module) so a launchd job can run
+// it headlessly. Reuses Norma's IG token/plumbing via the module; honors Norma's
+// simulation mode (records the real Graph shape, labeled simulated, until creds
+// are live). Idempotent per UTC day.
+//
+// Wire as a daily launchd job (staged in deploy/com.steve.dw-follow-counts.plist).
+// Run manually: node scripts/follow-counts-snapshot.js
+const path = require('path');
+
+// Load .env so NORMA_IG_* overrides (if any) and any future creds are visible.
+const fs = require('fs');
+try {
+ for (const line of fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+ }
+} catch { /* no .env */ }
+
+const mod = require('../modules/follow-counts');
+
+(async () => {
+ try {
+ const r = await mod.captureSnapshot();
+ const tag = r.simulated ? 'SIMULATED (awaiting live IG creds)' : 'LIVE';
+ console.log(`[follow-counts] ${new Date().toISOString()} snapshot ${r.ok ? 'ok' : 'FAILED'} · ${tag} · date=${r.date}`);
+ for (const c of (r.captured || [])) {
+ console.log(` @${c.handle}: followers=${c.followers} following=${c.following} media=${c.media} (${c.source})`);
+ }
+ if (!r.ok) { console.error(` error: ${r.error}`); process.exit(2); }
+ } catch (e) {
+ console.error(`[follow-counts] snapshot crashed: ${e.message}`);
+ process.exit(1);
+ }
+})();
← eac09ca CC module: proxy Connie cc-agent when no local CTCT token (D
·
back to Marketing Command Center
·
deploy.conf: fix MCC health URL to :9661 + guard gitignored fa747ed →