diff --git a/connector/src/competitors.js b/connector/src/competitors.js
new file mode 100644
index 0000000..f9704c3
--- /dev/null
+++ b/connector/src/competitors.js
@@ -0,0 +1,158 @@
+// Competitors per client: Instagram Business/Creator accounts the team tracks (added by hand or suggested by the AI).
+// Each refresh stores one snapshot per account per day (followers, posts, recent media), so growth can be charted.
+// Uses Instagram business_discovery through the client's (or the agency's) connected Instagram account.
+import fs from "node:fs";
+import path from "node:path";
+import { PROJECT_ROOT } from "./env.js";
+import * as meta from "./meta.js";
+
+const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json");
+const CLIENTS = path.join(PROJECT_ROOT, "clients");
+const KEEP_DAYS = 180;
+fs.mkdirSync(path.dirname(FILE), { recursive: true });
+
+const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
+const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE); };
+const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
+const today = () => new Date().toISOString().slice(0, 10);
+const handle = (u) => String(u || "").trim().replace(/^@/, "").replace(/^https?:\/\/(www\.)?instagram\.com\//i, "").replace(/[/?#].*$/, "").toLowerCase();
+
+function checkClient(slug) {
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
+ return slug;
+}
+function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } }
+
+/** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */
+async function viewerIgId(slug) {
+ const link = clientLink(slug);
+ if (link.igId) return link.igId;
+ const any = (await meta.listAccounts()).find((a) => a.instagram?.id);
+ if (!any) throw err("No Instagram account is connected to the agency's Meta account, so competitors can't be looked up.");
+ return any.instagram.id;
+}
+
+async function lookup(igId, username) {
+ try {
+ const b = await meta.igBusinessDiscovery(igId, username, { posts: 25 });
+ if (!b?.username) throw new Error("not found");
+ return b;
+ } catch (e) {
+ throw err(`Couldn't read @${username}. Instagram only shares public Business and Creator accounts; check the handle and that the account is public and professional. (${e.message})`);
+ }
+}
+
+function snapshotOf(b) {
+ return {
+ date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null,
+ name: b.name || null, bio: (b.biography || "").slice(0, 300),
+ media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })),
+ };
+}
+function addSnapshot(entry, snap) {
+ entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
+ // Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
+ entry.snapshots = entry.snapshots.map(({ media, ...rest }) => rest);
+ entry.snapshots.push(snap);
+ const cutoff = new Date(Date.now() - KEEP_DAYS * 86400000).toISOString().slice(0, 10);
+ entry.snapshots = entry.snapshots.filter((s) => s.date >= cutoff);
+}
+
+export function listTracked(client) {
+ checkClient(client);
+ return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null }));
+}
+
+export async function add(client, username, { by = "team", source = "manual", note = "" } = {}) {
+ checkClient(client);
+ const u = handle(username);
+ if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
+ const db = read();
+ const c = (db[client] ||= { accounts: [] });
+ if (c.accounts.some((a) => a.username === u)) throw err(`@${u} is already tracked for this client.`);
+ if (c.accounts.length >= 10) throw err("Track up to 10 competitors per client. Remove one first.");
+ const own = clientLink(client);
+ const b = await lookup(await viewerIgId(client), u);
+ if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account.");
+ const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
+ addSnapshot(entry, snapshotOf(b));
+ c.accounts.push(entry);
+ write(db);
+ return { username: u, name: entry.name };
+}
+
+export function remove(client, username) {
+ checkClient(client);
+ const db = read();
+ const u = handle(username);
+ const before = db[client]?.accounts?.length || 0;
+ if (db[client]) db[client].accounts = db[client].accounts.filter((a) => a.username !== u);
+ if ((db[client]?.accounts?.length || 0) === before) throw err("Not tracked.", 404);
+ write(db);
+}
+
+/** Refresh every tracked account for one client (or all clients). One snapshot per account per day. */
+export async function refresh(client) {
+ const db = read();
+ const slugs = client ? [checkClient(client)] : Object.keys(db);
+ const errors = [];
+ for (const slug of slugs) {
+ const c = db[slug];
+ if (!c?.accounts?.length) continue;
+ let igId;
+ try { igId = await viewerIgId(slug); } catch (e) { errors.push(`${slug}: ${e.message}`); continue; }
+ for (const a of c.accounts) {
+ try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); }
+ catch (e) { errors.push(`${slug} @${a.username}: ${e.message}`); }
+ }
+ }
+ write(db);
+ return { refreshed: slugs.length, errors };
+}
+
+const engagement = (m) => (m.likes ?? 0) + (m.comments ?? 0);
+/** Numbers for one account from its latest posts and snapshot history. */
+function stats({ followers, media, history }) {
+ const now = Date.now();
+ const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
+ const last12 = media.slice(0, 12);
+ // Median, not mean: one viral post would otherwise make a typical post look many times better than it is.
+ const sorted = last12.map(engagement).sort((x, y) => x - y);
+ const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null;
+ const mix = {};
+ for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1;
+ const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
+ const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
+ return {
+ followers,
+ growth30: base && followers != null && base.date !== today() ? followers - base.followers : null,
+ growthSince: base && base.date !== today() ? base.date : null,
+ postsPerWeek: media.length ? Math.round((recent.length / 4) * 10) / 10 : null,
+ avgEngagement: avgEng,
+ engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
+ formatMix: mix,
+ topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
+ };
+}
+
+/**
+ * Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
+ * own: { username, followers, posts:[{id,timestamp,like_count,comments_count,media_type,media_product_type,permalink,caption}], history:[{date, followers}] }
+ */
+export function compare(client, own) {
+ checkClient(client);
+ const accounts = (read()[client]?.accounts || []).map((a) => {
+ const latest = a.snapshots?.at(-1) || {};
+ return {
+ username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
+ ...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }),
+ series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
+ };
+ });
+ let you = null;
+ if (own) {
+ const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) }));
+ you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
+ }
+ return { you, competitors: accounts };
+}
diff --git a/connector/src/server.js b/connector/src/server.js
index 9eb3885..6efb982 100644
--- a/connector/src/server.js
+++ b/connector/src/server.js
@@ -11,6 +11,7 @@ import * as meta from "./meta.js";
import * as sched from "./schedule.js";
import * as lib from "./library.js";
import * as plan from "./plan.js";
+import * as competitors from "./competitors.js";
import * as extra from "./platforms/index.js";
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
@@ -205,4 +206,12 @@ server.tool("plan_update", "Update a content-plan item (status, date, brief, cap
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) },
wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent")));
+server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.",
+ { client: z.string() },
+ wrap(async ({ client }) => competitors.compare(client, null)));
+
+server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.",
+ { client: z.string(), username: z.string(), note: z.string().default("") },
+ wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
+
await server.connect(new StdioServerTransport());
diff --git a/connector/test/competitors.test.mjs b/connector/test/competitors.test.mjs
new file mode 100644
index 0000000..ba6a896
--- /dev/null
+++ b/connector/test/competitors.test.mjs
@@ -0,0 +1,33 @@
+// Competitor comparison maths, offline (no Instagram calls): median engagement, rate, growth, format mix, top posts.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-comp-"));
+process.env.QUORLA_COMPETITORS_FILE = path.join(TMP, "competitors.json");
+const { PROJECT_ROOT } = await import("../src/env.js");
+const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
+
+const day = (n) => new Date(Date.now() - n * 86400000);
+const media = [5, 10, 20, 40, 1000].map((likes, i) => ({ id: `m${i}`, at: day(i * 3).toISOString(), likes, comments: 0, type: i % 2 ? "Reel" : "Image", permalink: `https://x/${i}` }));
+
+test("compare: median engagement ignores one viral post; growth from history", { skip: !client && "no client folder" }, async () => {
+ fs.writeFileSync(process.env.QUORLA_COMPETITORS_FILE, JSON.stringify({
+ [client]: { accounts: [{ username: "rival", name: "Rival", source: "manual", snapshots: [
+ { date: day(20).toISOString().slice(0, 10), followers: 900 },
+ { date: day(0).toISOString().slice(0, 10), followers: 1000, media },
+ ] }] },
+ }));
+ const c = await import("../src/competitors.js");
+ const r = c.compare(client, { username: "me", followers: 500, posts: [], history: [] }).competitors[0];
+ assert.equal(r.avgEngagement, 20, "median of 5,10,20,40,1000");
+ assert.equal(r.engagementRate, 2);
+ assert.equal(r.growth30, 100);
+ assert.equal(r.postsPerWeek, 1.3, "5 posts in 28 days");
+ assert.deepEqual(r.formatMix, { Image: 3, Reel: 2 });
+ assert.equal(r.topPosts[0].likes, 1000);
+ assert.equal(c.listTracked(client)[0].username, "rival");
+ await assert.rejects(c.add(client, "not a handle!!"), /Instagram handle/);
+});
diff --git a/dashboard/server/index.js b/dashboard/server/index.js
index 1492ac2..f8ac4f3 100644
--- a/dashboard/server/index.js
+++ b/dashboard/server/index.js
@@ -16,6 +16,7 @@ import * as meta from "../../connector/src/meta.js";
import * as sched from "../../connector/src/schedule.js";
import * as lib from "../../connector/src/library.js";
import * as plan from "../../connector/src/plan.js";
+import * as competitors from "../../connector/src/competitors.js";
import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js";
@@ -297,6 +298,20 @@ app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {},
app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) })));
app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; }));
+// ---------- competitors per client (Instagram) ----------
+function ownForCompare(slug) {
+ const snap = metrics.readSnapshot(slug);
+ if (!snap?.ig) return null;
+ return {
+ username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [],
+ history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })),
+ };
+}
+app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); }));
+app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) })));
+app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; }));
+app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
+
// ---------- published posts and their numbers ----------
app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 })));
app.post("/api/published/refresh", h(async () => published.refreshStats()));
@@ -383,6 +398,7 @@ function scheduleNightly() {
setTimeout(async () => {
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); }
try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); }
+ try { console.log("Competitors:", await competitors.refresh()); } catch (e) { console.error("Competitors refresh failed", e); }
scheduleNightly();
}, next - new Date());
}
diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx
index ee639c9..1671f01 100644
--- a/dashboard/web/src/App.jsx
+++ b/dashboard/web/src/App.jsx
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
-import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays } from "lucide-react";
+import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords } from "lucide-react";
import { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx";
@@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx";
import Publishing from "./pages/Publishing.jsx";
import Published from "./pages/Published.jsx";
import Calendar from "./pages/Calendar.jsx";
+import Competitors from "./pages/Competitors.jsx";
import Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx";
@@ -25,6 +26,7 @@ const NAV = [
{ to: "/library", label: "Content library", icon: FolderUp },
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
{ to: "/published", label: "Published", icon: BarChart3 },
+ { to: "/competitors", label: "Competitors", icon: Swords },
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
{ to: "/connections", label: "Connections", icon: Plug },
{ to: "/team", label: "Team", icon: Users2 },
@@ -122,6 +124,7 @@ export default function App() {
How each client's Instagram compares with the accounts they compete with{lastChecked ? ` · checked ${fmtDate(lastChecked)}` : ""}. Numbers refresh nightly.
+Coral is this client; navy are competitors.
+This client's own Instagram isn't linked or hasn't been refreshed yet, so it isn't in the comparison.
} + > + )} + + {adding &&