← back to Calendar Agentabrams

schedule.mjs

79 lines

// schedule.mjs — projection of the BHV Instagram posting calendar that CANNOT diverge
// from the poster, because it reads the poster's own ledgers and mirrors its exact pick
// logic (rankByLRU over the film lists, ignoring dry-run entries). No hardcoded offsets.
//
// Source of truth = the poster dir (BHV_POSTER_DIR). Falls back to bundled ./data copies
// of the film lists if the poster dir isn't reachable (e.g. deployed off-box) — in that
// case the ledger seed is empty and it projects from a clean rotation.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const POSTER = process.env.BHV_POSTER_DIR || path.join(os.homedir(), 'Projects/beverlyhillsvideos');

const readJson = (p, fallback) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; } };
const readLedger = (p) => { try { return fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { return []; } };

// Prefer the poster's live film lists; fall back to bundled copies.
function films(kind) {
  const name = kind === 'store' ? 'store-films.json' : 'films.json';
  return readJson(path.join(POSTER, 'data', name), null) || readJson(path.join(HERE, 'data', name), []);
}
function ledgerSeed(kind) {
  const name = kind === 'store' ? 'ledger-store.jsonl' : 'ledger.jsonl';
  // Prefer the live poster ledger (Mac2); fall back to a bundled snapshot synced from
  // Mac2 after each post (so a Kamatera-deployed calendar stays current, not clean-slate).
  let rows = readLedger(path.join(POSTER, 'social/poster', name));
  if (!rows.length) rows = readLedger(path.join(HERE, 'data', name));
  const last = new Map();
  for (const e of rows) if (e.slug && !e.dryRun) last.set(e.slug, e.ts); // mirror poster: real posts only
  return last;
}

const SLOT_HOURS = [0, 6, 12, 18];
const kindForHour = (h) => (Math.floor(h / 6) % 2 === 0 ? 'restaurant' : 'store');

// Exact mirror of the poster's rankByLRU: least-recently-posted first, never-posted
// (empty) first in stable file order.
function pickLRU(list, last) {
  return list
    .map((f) => ({ f, last: last.get(f.slug) || '' }))
    .sort((a, b) => (a.last < b.last ? -1 : a.last > b.last ? 1 : 0))[0]?.f;
}

function slotsBetween(from, to) {
  const out = [];
  const d = new Date(from); d.setHours(0, 0, 0, 0);
  for (let day = new Date(d); day.getTime() <= to; day.setDate(day.getDate() + 1)) {
    for (const h of SLOT_HOURS) {
      const t = new Date(day); t.setHours(h, 0, 0, 0);
      if (t.getTime() >= from && t.getTime() <= to) out.push({ ms: t.getTime(), hour: h });
    }
  }
  return out.sort((a, b) => a.ms - b.ms);
}

export function upcoming({ now = Date.now(), days = 14 } = {}) {
  const lists = { restaurant: films('restaurant'), store: films('store') };
  const seeds = { restaurant: ledgerSeed('restaurant'), store: ledgerSeed('store') };
  const items = [];
  for (const s of slotsBetween(now, now + days * 864e5)) {
    const kind = kindForHour(s.hour);
    const film = pickLRU(lists[kind], seeds[kind]);
    if (!film) continue;
    seeds[kind].set(film.slug, new Date(s.ms).toISOString()); // advance simulated ledger
    items.push({
      iso: new Date(s.ms).toISOString(), epoch: s.ms, kind,
      name: film.name, category: film.category || (kind === 'store' ? 'Store' : 'Restaurant'),
      slug: film.slug, src: film.src,
    });
  }
  return items;
}

export function stats() {
  return { restaurants: films('restaurant').length, stores: films('store').length, slotsPerDay: SLOT_HOURS.length };
}