From 9e263cd1f8815444c6f1e2ef519797ed17e580d3 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:16:26 -0400 Subject: [PATCH] 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 --- dashboard/server/index.js | 6 +++++- dashboard/server/published.js | 30 ++++++++++++++++++++++++++ dashboard/web/src/pages/Published.jsx | 12 +++++++++++ dashboard/web/src/pages/Publishing.jsx | 2 +- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 88ea9b7..c5e577e 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -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 }); diff --git a/dashboard/server/published.js b/dashboard/server/published.js index 2067916..fc1aa96 100644 --- a/dashboard/server/published.js +++ b/dashboard/server/published.js @@ -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))] }; +} diff --git a/dashboard/web/src/pages/Published.jsx b/dashboard/web/src/pages/Published.jsx index fb772c2..d71ad27 100644 --- a/dashboard/web/src/pages/Published.jsx +++ b/dashboard/web/src/pages/Published.jsx @@ -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() { )} + {best && ( +
+ Best times to post + {best.best?.slots?.length ? best.best.slots.map((s) => ( + {s.day} {s.time}+{s.lift}% reach({s.posts} posts) + )) : Not enough Instagram posts with reach yet (needs 8; has {best.best?.basedOn ?? 0}). Using general times: {best.times.join(", ")}.} + {best.best?.slots?.length > 0 && From the last 4 months of Instagram posts, {best.timezone.replace("_", " ")} time. An early signal, not a rule.} +
+ )} + {!data && !error ? : data && !rows.length ? (
Try a longer period or another client. Instagram and Facebook posts appear after the next numbers refresh.
) : data && ( diff --git a/dashboard/web/src/pages/Publishing.jsx b/dashboard/web/src/pages/Publishing.jsx index 2a4c5c6..867e98b 100644 --- a/dashboard/web/src/pages/Publishing.jsx +++ b/dashboard/web/src/pages/Publishing.jsx @@ -166,7 +166,7 @@ function Compose({ clients, onClose, onSaved }) { {TYPES.map(([v, l]) => )} - + `${s.day} ${s.time} (+${s.lift}% reach)`).join(", ")}` : `Good times: ${tz.times.join(", ")}`}>
set("date", e.target.value)} /> set("time", e.target.value)} />