metatroncubeswdev 9e263cd1f8 Best time to post, learned from each client's own Instagram reach
- Last 120 days of posts, reach vs the client's median, by weekday and 3-hour window (client time zone)
- Needs 8+ posts and 2+ per slot; shown on Published (client filter) and as the New post time hint
- Falls back to general market times when there isn't enough data

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:16:26 -04:00

195 lines
11 KiB
JavaScript

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 [best, setBest] = useState(null);
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]);
useEffect(() => { setBest(null); if (f.client) api(`/api/schedule/suggest?client=${f.client}`).then(setBest).catch(() => {}); }, [f.client]);
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>
)}
{best && (
<div className="panel px-4 py-3 text-[13px] flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span className="font-semibold">Best times to post</span>
{best.best?.slots?.length ? best.best.slots.map((s) => (
<span key={`${s.day}${s.window}`} className="inline-flex items-center gap-1.5"><b>{s.day} {s.time}</b><span className="text-ok">+{s.lift}% reach</span><span className="text-faint">({s.posts} posts)</span></span>
)) : <span className="text-muted">Not enough Instagram posts with reach yet (needs 8; has {best.best?.basedOn ?? 0}). Using general times: {best.times.join(", ")}.</span>}
{best.best?.slots?.length > 0 && <span className="text-[12px] text-muted ml-auto">From the last 4 months of Instagram posts, {best.timezone.replace("_", " ")} time. An early signal, not a rule.</span>}
</div>
)}
{!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>
);
}