← 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 };