Published page: every client's posts with their numbers

- Feed merges Instagram/Facebook snapshot posts (with insights) and everything Quorla published
- Filters (client, platform, 7/30/90 days), sort by interactions/reach/views, via-Quorla badge
- YouTube view/like/comment counts fetched nightly and on demand (1 quota unit per 50 videos)
- Snapshots now keep post thumbnails (IG media/thumbnail url, FB full_picture)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 09:15:34 -04:00
parent e5f24799ea
commit 999e9fbcec
7 changed files with 342 additions and 3 deletions

View File

@ -148,7 +148,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
const r = await graph(`${igId}/media`, {
fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count",
fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count",
limit,
});
const media = r.data || [];
@ -204,7 +204,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
needPage(account);
const r = await graph(
`${account.page_id}/posts`,
{ fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
{ fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
{ token: account.page_token }
);
return (r.data || []).map((p) => ({
@ -212,6 +212,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
created_time: p.created_time,
permalink_url: p.permalink_url,
message: p.message,
picture: p.full_picture || null,
reactions: p.reactions?.summary?.total_count ?? 0,
comments: p.comments?.summary?.total_count ?? 0,
shares: p.shares?.count ?? 0,

View File

@ -97,6 +97,19 @@ export async function setTarget(connId, targetId) {
return vault.updateConnection(connId, { target: t });
}
/** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */
export async function postStats(platform, client, ids) {
const a = adapter(platform);
if (!a.stats || !ids.length) return {};
const conn = vault.connectionFor(client, platform);
if (!conn) return {};
const { tokens } = await tokensFor(conn.id);
if (platform === "youtube") quota.ensure("youtube", null, "read");
const out = await a.stats(tokens, ids);
if (platform === "youtube") quota.record("youtube", null, "read");
return out;
}
/** Re-read the account (name, followers) to check the connection still works. */
export async function checkConnection(connId) {
const { conn, tokens } = await tokensFor(connId);

View File

@ -32,6 +32,16 @@ export default {
};
},
/** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */
async stats(tokens, ids) {
if (!ids.length) return {};
const { data } = await request("YouTube", `${API}/videos`, { token: tokens.accessToken, query: { part: "statistics,snippet", id: ids.slice(0, 50).join(",") } });
return Object.fromEntries((data.items || []).map((v) => [v.id, {
views: Number(v.statistics?.viewCount ?? 0), likes: Number(v.statistics?.likeCount ?? 0), comments: Number(v.statistics?.commentCount ?? 0),
thumb: v.snippet?.thumbnails?.medium?.url || v.snippet?.thumbnails?.default?.url || null,
}]));
},
async publish({ tokens, post, opts, caption }) {
const src = (post.media || [])[0];
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");

View File

@ -8,6 +8,7 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin,
import * as users from "./users.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
import * as published from "./published.js";
import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js";
import * as docs from "./docs.js";
@ -288,6 +289,10 @@ app.get("/api/schedule/media", h(async (req, res) => {
}));
// ---------- 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()));
// ---------- content library: team uploads finished work per client ----------
const who = (req) => req.user.name || req.user.email;
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
@ -369,6 +374,7 @@ function scheduleNightly() {
if (next <= new Date()) next.setDate(next.getDate() + 1);
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); }
scheduleNightly();
}, next - new Date());
}

View File

@ -0,0 +1,124 @@
// "Published" feed: every post that went out on a client's accounts, with its numbers.
// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too);
// other platforms come from what Quorla published, with YouTube counts fetched on refresh.
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "./config.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
import * as sched from "../../connector/src/schedule.js";
import * as extra from "../../connector/src/platforms/index.js";
const STATS_FILE = path.join(DATA_DIR, "post-stats.json");
const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } };
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; };
function igItem(client, p, viaQuorla) {
const i = p.insights || {};
const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares);
return {
key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram",
id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null,
format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image",
thumb: p.thumbnail_url || p.media_url || null,
metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares },
interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares),
viaQuorla,
};
}
function fbItem(client, p, viaQuorla) {
return {
key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook",
id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post",
thumb: p.picture || null,
metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) },
interactions: sum(p.reactions, p.comments, p.shares),
viaQuorla,
};
}
/** All published posts, newest first. Filters: client slug, platform id, days back. */
export function feed({ client, platform, days = 30 } = {}) {
const since = Date.now() - Number(days || 30) * 86400000;
const all = clients.listClients().filter((c) => !client || c.slug === client);
const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status));
const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String)));
const stats = readStats().posts;
const items = [];
for (const c of all) {
const snap = metrics.readSnapshot(c.slug);
for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id))));
for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id))));
}
const seen = new Set(items.map((i) => i.key));
const byClient = Object.fromEntries(all.map((c) => [c.slug, c]));
// Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot.
for (const p of quorla) {
const c = byClient[p.client];
if (!c) continue;
for (const [pl, r] of Object.entries(p.results || {})) {
if (!r?.id || r.cancelled) continue;
const key = `${pl}:${r.id}`;
if (seen.has(key)) continue;
const s = stats[key] || {};
items.push({
key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id),
at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null,
format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image",
thumb: s.thumb || null, media: p.media?.[0] || null,
metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null },
interactions: sum(s.likes, s.comments),
viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null,
});
seen.add(key);
}
}
const list = items
.filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since)
.sort((a, b) => new Date(b.at) - new Date(a.at));
const reachKnown = list.filter((i) => i.metrics.reach !== null);
return {
items: list,
totals: {
posts: list.length,
viaQuorla: list.filter((i) => i.viaQuorla).length,
reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0),
interactions: list.reduce((s, i) => s + (i.interactions || 0), 0),
avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0,
},
snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])),
statsAt: readStats().fetchedAt,
};
}
/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */
export async function refreshStats() {
const since = Date.now() - 90 * 86400000;
const want = {};
for (const p of sched.listPosts({})) {
if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue;
for (const [pl, r] of Object.entries(p.results || {})) {
if (pl !== "youtube" || !r?.id) continue;
((want[p.client] ||= {})[pl] ||= []).push(String(r.id));
}
}
const out = readStats();
const errors = [];
for (const [client, byPl] of Object.entries(want)) {
for (const [pl, ids] of Object.entries(byPl)) {
try {
const s = await extra.postStats(pl, client, ids);
for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v;
} catch (e) { errors.push(`${client} ${pl}: ${e.message}`); }
}
}
out.fetchedAt = new Date().toISOString();
fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1));
return { fetchedAt: out.fetchedAt, errors };
}

View File

@ -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 } from "lucide-react";
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3 } from "lucide-react";
import { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx";
@ -10,6 +10,7 @@ import Team from "./pages/Team.jsx";
import Runs from "./pages/Runs.jsx";
import Settings from "./pages/Settings.jsx";
import Publishing from "./pages/Publishing.jsx";
import Published from "./pages/Published.jsx";
import Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx";
@ -21,6 +22,7 @@ const NAV = [
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
{ to: "/library", label: "Content library", icon: FolderUp },
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
{ to: "/published", label: "Published", icon: BarChart3 },
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
{ to: "/connections", label: "Connections", icon: Plug },
{ to: "/team", label: "Team", icon: Users2 },
@ -116,6 +118,7 @@ export default function App() {
<Route path="/clients/:slug" element={<Client />} />
<Route path="/library" element={<Library />} />
<Route path="/publishing" element={<Publishing />} />
<Route path="/published" element={<Published />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/connections" element={<Connections />} />
<Route path="/team" element={<Team />} />

View File

@ -0,0 +1,182 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExternalLink, ImageOff, RefreshCw, Send } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
const PLATFORMS = {
instagram: { name: "Instagram", color: "#d62976" },
facebook: { name: "Facebook", color: "#1877f2" },
youtube: { name: "YouTube", color: "#ff0000" },
tiktok: { name: "TikTok", color: "#010101" },
gbp: { name: "Google Business", color: "#4285f4" },
linkedin: { name: "LinkedIn", color: "#0a66c2" },
pinterest: { name: "Pinterest", color: "#e60023" },
threads: { name: "Threads", color: "#000000" },
reddit: { name: "Reddit", color: "#ff4500" },
};
const COLS = [["reach", "Reach"], ["views", "Views"], ["likes", "Likes"], ["comments", "Comments"], ["saves", "Saves"], ["shares", "Shares"]];
const SORTS = { newest: "Newest first", interactions: "Most interactions", reach: "Most reach", views: "Most views" };
const PAGE = 50;
const isVideo = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m || ""));
function Thumb({ item }) {
const [broken, setBroken] = useState(false);
const local = item.media ? `/api/schedule/media?path=${encodeURIComponent(item.media)}` : null;
const p = PLATFORMS[item.platform] || { name: item.platform, color: "#6b6e78" };
const box = "size-14 rounded-md shrink-0 overflow-hidden bg-canvas border border-line";
if (!broken && item.thumb) return <img src={item.thumb} alt="" loading="lazy" className={cx(box, "object-cover")} onError={() => setBroken(true)} />;
if (local && isVideo(item.media)) return <video src={local} muted preload="metadata" className={cx(box, "object-cover bg-black")} />;
if (local) return <img src={local} alt="" loading="lazy" className={cx(box, "object-cover")} />;
return (
<div className={cx(box, "grid place-items-center text-white font-bold text-[13px]")} style={{ background: p.color }} aria-hidden="true">
{broken ? <ImageOff className="size-4 opacity-80" /> : p.name[0]}
</div>
);
}
export default function Published() {
const { isAdmin } = useAuth();
const [clients, setClients] = useState([]);
const [f, setF] = useState({ client: "", platform: "", days: 30, sort: "newest" });
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [shown, setShown] = useState(PAGE);
const [refreshing, setRefreshing] = useState(false);
const set = (k, v) => { setF((s) => ({ ...s, [k]: v })); setShown(PAGE); };
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
const load = useCallback(() => {
const q = new URLSearchParams({ days: String(f.days) });
if (f.client) q.set("client", f.client);
if (f.platform) q.set("platform", f.platform);
setError(null);
return api(`/api/published?${q}`).then(setData).catch(setError);
}, [f.client, f.platform, f.days]);
useEffect(() => { load(); }, [load]);
const rows = useMemo(() => {
const list = [...(data?.items || [])];
const key = f.sort === "interactions" ? (i) => i.interactions ?? -1 : f.sort === "reach" ? (i) => i.metrics.reach ?? -1 : f.sort === "views" ? (i) => i.metrics.views ?? -1 : null;
if (key) list.sort((a, b) => key(b) - key(a));
return list;
}, [data, f.sort]);
const refreshStats = async () => {
setRefreshing(true);
try { await api("/api/published/refresh", { method: "POST" }); await load(); } catch (e) { setError(e); }
setRefreshing(false);
};
const t = data?.totals;
const platformsInData = useMemo(() => [...new Set((data?.items || []).map((i) => i.platform))], [data]);
const oldestSnap = data ? Object.values(data.snapshotsAt).filter(Boolean).sort()[0] : null;
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="text-[22px] font-extrabold">Published</h1>
<p className="text-muted mt-0.5">
Everything that went out on your clients' accounts, with its numbers
{oldestSnap ? ` · Instagram and Facebook as of ${timeAgo(oldestSnap)}` : ""}
</p>
</div>
{isAdmin && <Button icon={RefreshCw} loading={refreshing} onClick={refreshStats}>Refresh YouTube numbers</Button>}
</header>
<div className="flex flex-wrap gap-2 items-center">
<select id="pub-client" aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => set("client", e.target.value)}>
<option value="">All clients</option>
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
<select id="pub-platform" aria-label="Platform" className="input h-9 w-auto" value={f.platform} onChange={(e) => set("platform", e.target.value)}>
<option value="">All platforms</option>
{Object.entries(PLATFORMS).filter(([id]) => !data || platformsInData.includes(id) || f.platform === id).map(([id, p]) => <option key={id} value={id}>{p.name}</option>)}
</select>
<div className="inline-flex rounded-md border border-line-strong bg-white p-0.5" role="group" aria-label="Period">
{[7, 30, 90].map((d) => (
<button key={d} type="button" onClick={() => set("days", d)} aria-pressed={f.days === d}
className={cx("h-8 px-3 rounded text-[13px] font-medium", f.days === d ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
{d} days
</button>
))}
</div>
<select id="pub-sort" aria-label="Sort" className="input h-9 w-auto ml-auto" value={f.sort} onChange={(e) => set("sort", e.target.value)}>
{Object.entries(SORTS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</div>
<ErrorNote error={error} />
{t && (
<StatRow className="grid-cols-2 md:grid-cols-4">
<StatCell><Stat label="Posts" value={fmt(t.posts)} note={`${fmt(t.viaQuorla)} sent through Quorla`} /></StatCell>
<StatCell><Stat label="Accounts reached" value={fmt(t.reach)} note="Instagram, summed per post" /></StatCell>
<StatCell><Stat label="Interactions" value={fmt(t.interactions)} note="likes, comments, saves, shares" /></StatCell>
<StatCell><Stat label="Per post" value={fmt(t.avgInteractions)} note="average interactions" /></StatCell>
</StatRow>
)}
{!data && !error ? <Spinner /> : data && !rows.length ? (
<div className="panel"><Empty icon={Send} title="Nothing published in this period">Try a longer period or another client. Instagram and Facebook posts appear after the next numbers refresh.</Empty></div>
) : data && (
<div className="panel overflow-x-auto">
<table className="tbl min-w-[960px]">
<thead>
<tr>
<th>Post</th>
<th>Platform</th>
{COLS.map(([k, l]) => <th key={k} className="text-right">{l}</th>)}
<th className="text-right">Interactions</th>
<th><span className="sr-only">Open</span></th>
</tr>
</thead>
<tbody>
{rows.slice(0, shown).map((i) => {
const p = PLATFORMS[i.platform] || { name: i.platform, color: "#6b6e78" };
return (
<tr key={i.key}>
<td className="max-w-[420px]">
<div className="flex gap-3 items-start">
<Thumb item={i} />
<div className="min-w-0">
<div className="text-[13px] text-ink line-clamp-2">{i.caption || <span className="text-faint">No caption</span>}</div>
<div className="text-[12px] text-muted mt-1 flex flex-wrap gap-x-2">
<span className="font-medium text-ink-2">{i.clientName}</span>
<span>{i.format}</span>
<span>{fmtDate(i.at, { month: "short", day: "numeric", year: "numeric" })}</span>
{i.viaQuorla && <Badge tone="brand">via Quorla</Badge>}
</div>
{i.note && <div className="text-[12px] text-muted mt-0.5">{i.note}</div>}
</div>
</div>
</td>
<td className="whitespace-nowrap">
<span className="inline-flex items-center gap-1.5 text-[13px]"><span className="size-2 rounded-full" style={{ background: p.color }} aria-hidden="true" />{p.name}</span>
</td>
{COLS.map(([k]) => <td key={k} className="text-right num text-[13px]">{fmt(i.metrics[k])}</td>)}
<td className="text-right num font-semibold">{fmt(i.interactions)}</td>
<td className="text-right">
{i.permalink && <a href={i.permalink} target="_blank" rel="noreferrer" className="inline-flex p-1.5 rounded hover:bg-canvas text-muted" aria-label={`Open on ${p.name}`}><ExternalLink className="size-4" /></a>}
</td>
</tr>
);
})}
</tbody>
</table>
{rows.length > shown && (
<div className="border-t border-line p-3 text-center">
<Button size="sm" onClick={() => setShown((n) => n + PAGE)}>Show {Math.min(PAGE, rows.length - shown)} more of {fmt(rows.length - shown)}</Button>
</div>
)}
</div>
)}
<p className="text-[12px] text-muted">
Instagram and Facebook numbers refresh nightly with each client's snapshot. Reach and saves aren't available for Facebook posts.
YouTube counts refresh nightly{data?.statsAt ? ` (last ${timeAgo(data.statsAt)})` : ""}. TikTok and Google Business Profile don't share per-post numbers with Quorla yet.
</p>
</div>
);
}