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