← back to Norma
agents/instagram-agent/calendar.js
155 lines
/**
* calendar.js — cross-account "what got posted each day" calendar for norma-instagram (:9810).
*
* The /live board is ACCOUNT-major (pick one account → its posts grouped by day). This is the
* transpose: DAY-major (pick a set of accounts → every account's posts grouped by calendar day),
* so Steve can see at a glance what went out each day across all Instagram accounts, or a
* selected subset.
*
* Read-only, same doctrine as live-media.js: a PAGE VIEW / these APIs only READ the per-account
* caches that live-media's hourly cron + the /live "Refresh" button populate — never a Graph API
* call. Tombstoned (hidden/deleted) posts are excluded, matching /live exactly.
*
* Day boundary: posts are bucketed by LOCAL calendar day (server tz, PT on Mac2) because a
* daily-posting calendar is a local-time concept. Meta timestamps carry a tz offset so the
* conversion is unambiguous.
*
* Routes (registered before start()'s 404 catch-all):
* GET /calendar → viewer HTML
* GET /api/live/calendar?month=YYYY-MM&accounts=all|id,id → month grid: per-day counts + thumbs
* GET /api/live/calendar/day?date=YYYY-MM-DD&accounts=… → full posts for one day, across accounts
*/
const fs = require('fs');
const path = require('path');
const { listAccounts, readCache, readTombstones } = require('./live-media');
const VIEWER = path.join(__dirname, 'public', 'calendar-viewer.html');
const THUMBS_PER_DAY = 8; // max thumbnails collected per calendar day in the month grid
// Stable per-account color by golden-angle hue rotation — deterministic, no config dependency,
// every account maximally distinct from its neighbours.
function accountColor(i) { return `hsl(${Math.round((i * 137.508) % 360)} 66% 60%)`; }
function roster() {
return listAccounts().map((a, i) => ({
handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name || '', color: accountColor(i),
}));
}
// local YYYY-MM-DD for an ISO timestamp
function dayKey(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// Resolve the accounts filter param ('all' | 'id,id,…') to a validated (whitelisted) id list.
function selectIds(accountsParam, rost) {
const all = rost.map((r) => r.ig_user_id);
if (!accountsParam || accountsParam === 'all') return all;
const allow = new Set(all);
const picked = String(accountsParam).split(',').map((s) => s.trim()).filter((id) => allow.has(id));
return picked.length ? picked : all; // empty/garbage selection → treat as all rather than blank
}
/**
* Month grid: for each day in the given YYYY-MM, count posts across the selected accounts and
* collect a few thumbnails + per-account breakdown. Payload stays small (thumbs capped) so the
* grid loads fast even for "all 35 accounts".
*/
function monthView({ month, accountsParam }) {
const rost = roster();
const byId = Object.fromEntries(rost.map((r) => [r.ig_user_id, r]));
const sel = selectIds(accountsParam, rost);
const tombs = readTombstones();
const days = {}; // 'YYYY-MM-DD' → { count, byAccount:{id:n}, thumbs:[…] }
const perAccountMonth = {}; // id → count this month (for the filter panel)
let monthCount = 0;
const incomplete = [];
for (const id of sel) {
const cache = readCache(id);
if (cache && cache.complete === false) incomplete.push(byId[id].handle);
for (const p of Object.values((cache && cache.byId) || {})) {
if (!p.ts) continue;
if (p.permalink && (tombs.has(p.permalink) || tombs.has(p.id))) continue;
const key = dayKey(p.ts);
if (!key || !key.startsWith(month)) continue;
const d = (days[key] ||= { count: 0, byAccount: {}, thumbs: [] });
d.count++; monthCount++;
d.byAccount[id] = (d.byAccount[id] || 0) + 1;
perAccountMonth[id] = (perAccountMonth[id] || 0) + 1;
if (d.thumbs.length < THUMBS_PER_DAY && p.thumb) {
d.thumbs.push({ thumb: p.thumb, permalink: p.permalink, handle: byId[id].handle, color: byId[id].color });
}
}
}
// cache freshness across selected accounts (honest "as of" signal)
let newest = null;
for (const id of sel) { const c = readCache(id); if (c && c.cached_at && (!newest || c.cached_at > newest)) newest = c.cached_at; }
return {
month,
accounts: rost.map((r) => ({ ...r, month_count: perAccountMonth[r.ig_user_id] || 0 })),
selected: sel,
days,
month_count: monthCount,
active_days: Object.keys(days).length,
incomplete_accounts: [...new Set(incomplete)],
cached_at: newest,
};
}
/** Full post list for ONE local calendar day, across the selected accounts, chronological. */
function dayView({ date, accountsParam }) {
const rost = roster();
const byId = Object.fromEntries(rost.map((r) => [r.ig_user_id, r]));
const sel = selectIds(accountsParam, rost);
const tombs = readTombstones();
const posts = [];
for (const id of sel) {
const cache = readCache(id);
for (const p of Object.values((cache && cache.byId) || {})) {
if (!p.ts || dayKey(p.ts) !== date) continue;
if (p.permalink && (tombs.has(p.permalink) || tombs.has(p.id))) continue;
const a = byId[id];
posts.push({ ...p, ig_user_id: id, handle: a.handle, color: a.color });
}
}
posts.sort((x, y) => (x.ts || '').localeCompare(y.ts || '')); // earliest first within the day
const byAccount = {};
for (const p of posts) byAccount[p.handle] = (byAccount[p.handle] || 0) + 1;
return { date, count: posts.length, byAccount, posts, liveEnabled: /^(1|true|yes|on)$/i.test(process.env.IG_LIVE_DELETE || '') };
}
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function registerCalendarRoutes(app) {
app.get('/calendar', (req, res) => {
if (!fs.existsSync(VIEWER)) return res.status(404).send('calendar-viewer.html missing');
res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
});
app.get('/api/live/calendar', (req, res) => {
try {
const month = /^\d{4}-\d{2}$/.test(req.query.month || '') ? req.query.month : currentMonth();
res.json(monthView({ month, accountsParam: req.query.accounts || 'all' }));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/live/calendar/day', (req, res) => {
try {
const date = req.query.date || '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
res.json(dayView({ date, accountsParam: req.query.accounts || 'all' }));
} catch (e) { res.status(500).json({ error: e.message }); }
});
}
module.exports = { registerCalendarRoutes, monthView, dayView };