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:
parent
21e49b442f
commit
9e263cd1f8
@ -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 });
|
||||
|
||||
@ -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))] };
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user