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>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:16:26 -04:00
parent 21e49b442f
commit 9e263cd1f8
4 changed files with 48 additions and 2 deletions

View File

@ -294,7 +294,11 @@ const clientAccount = async (slug) => {
const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); };
app.get("/api/schedule", h(async (req) => ({ posts: sched.listPosts({ client: req.query.client || undefined }), publishing: process.env.ALLOW_PUBLISH === "true" })));
app.get("/api/schedule/suggest", h(async (req) => { const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client); return { timezone: c.timezone, times: sched.suggestedTimes(c.timezone) }; }));
app.get("/api/schedule/suggest", h(async (req) => {
const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client);
const best = published.bestTimes(c.slug, c.timezone);
return { timezone: c.timezone, times: best.times.length ? best.times : sched.suggestedTimes(c.timezone), best };
}));
app.post("/api/schedule/upload", express.raw({ type: "*/*", limit: "500mb" }), h(async (req) => {
const name = String(req.headers["x-filename"] || "upload").replace(/[^\w.\-]+/g, "_").slice(-80);
if (!/\.(jpe?g|png|mp4|mov|m4v)$/i.test(name)) throw Object.assign(new Error("Upload a JPG, PNG, MP4 or MOV file."), { status: 400 });

View File

@ -122,3 +122,33 @@ export async function refreshStats() {
fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1));
return { fetchedAt: out.fetchedAt, errors };
}
const WINDOWS = [[6, 9], [9, 12], [12, 15], [15, 18], [18, 21], [21, 24]];
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length ? (s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2) : null; };
/**
* When this client's Instagram posts do best, from their own last 120 days: each post's reach divided by the
* client's median reach, grouped by weekday and 3-hour window (client's local time). Needs 8+ posts with reach.
*/
export function bestTimes(slug, timezone) {
const posts = feed({ client: slug, platform: "instagram", days: 120 }).items.filter((i) => i.metrics.reach != null && i.metrics.reach > 0);
if (posts.length < 8) return { basedOn: posts.length, slots: [], times: [] };
const base = median(posts.map((p) => p.metrics.reach));
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
const groups = {};
for (const p of posts) {
const parts = Object.fromEntries(fmt.formatToParts(new Date(p.at)).map((x) => [x.type, x.value]));
const hour = Number(parts.hour) % 24, minute = Number(parts.minute);
const w = WINDOWS.findIndex(([a, b]) => hour >= a && hour < b);
if (w < 0) continue;
const day = WEEKDAYS.indexOf(parts.weekday.slice(0, 3));
(groups[`${day}-${w}`] ||= { day, w, ratios: [], minutes: [] }).ratios.push(p.metrics.reach / base);
groups[`${day}-${w}`].minutes.push(hour * 60 + minute);
}
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(Math.round(m % 60 / 15) * 15 % 60).padStart(2, "0")}`;
const slots = Object.values(groups).filter((g) => g.ratios.length >= 2)
.map((g) => ({ day: WEEKDAYS[g.day], window: `${String(WINDOWS[g.w][0]).padStart(2, "0")}:00–${String(WINDOWS[g.w][1] % 24).padStart(2, "0")}:00`, time: hhmm(median(g.minutes)), posts: g.ratios.length, lift: Math.round((median(g.ratios) - 1) * 100) }))
.filter((s) => s.lift > 0).sort((a, b) => b.lift - a.lift).slice(0, 3);
return { basedOn: posts.length, medianReach: base, slots, times: [...new Set(slots.map((s) => s.time))] };
}

View File

@ -44,6 +44,7 @@ export default function Published() {
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(() => {}); }, []);
@ -55,6 +56,7 @@ export default function Published() {
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 || [])];
@ -119,6 +121,16 @@ export default function Published() {
</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 && (

View File

@ -166,7 +166,7 @@ function Compose({ clients, onClose, onSaved }) {
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={!tz ? null : tz.best?.slots?.length ? `Best for this client: ${tz.best.slots.map((s) => `${s.day} ${s.time} (+${s.lift}% reach)`).join(", ")}` : `Good times: ${tz.times.join(", ")}`}>
<div className="flex gap-2">
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
<input id="c-time" type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} />