[object Object]

← back to Calendar Agentabrams

calendar: read poster ledgers + mirror rankByLRU (drop OFFSET fudge)

cf2dfe987252a39d0f5ec65aaf43b9b4d2f3cd3c · 2026-08-06 16:23:59 -0700 · Steve Abrams

Cody caught the calendar (round-robin + hardcoded OFFSET) diverging from the
poster (LRU-from-ledger). Now reads the poster's own ledgers (dryRun-filtered)
and mirrors its exact pick logic, so calendar == poster by construction.

Files touched

Diff

commit cf2dfe987252a39d0f5ec65aaf43b9b4d2f3cd3c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 16:23:59 2026 -0700

    calendar: read poster ledgers + mirror rankByLRU (drop OFFSET fudge)
    
    Cody caught the calendar (round-robin + hardcoded OFFSET) diverging from the
    poster (LRU-from-ledger). Now reads the poster's own ledgers (dryRun-filtered)
    and mirrors its exact pick logic, so calendar == poster by construction.
---
 schedule.mjs | 86 ++++++++++++++++++++++++++++++++----------------------------
 1 file changed, 46 insertions(+), 40 deletions(-)

diff --git a/schedule.mjs b/schedule.mjs
index cb6e20e..334cc2d 100644
--- a/schedule.mjs
+++ b/schedule.mjs
@@ -1,69 +1,75 @@
-// schedule.mjs — deterministic projection of the BHV Instagram posting calendar.
-// The launchd job (com.steve.bhv-ig-6h) posts at 00/06/12/18 local, alternating
-// restaurant/store by slot parity. Rotation is round-robin over the bundled film lists.
-// Pure function of time → self-correcting as real time passes (no stored state to drift).
+// 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 stores = JSON.parse(fs.readFileSync(path.join(HERE, 'data/store-films.json'), 'utf8'));
-const restaurants = JSON.parse(fs.readFileSync(path.join(HERE, 'data/films.json'), 'utf8'));
+const POSTER = process.env.BHV_POSTER_DIR || path.join(os.homedir(), 'Projects/beverlyhillsvideos');
 
-const SLOT_HOURS = [0, 6, 12, 18];
-// Anchor: first live store slot (Gucci) — 2026-08-06 18:00 local. Rotation counts from here.
-const ANCHOR = new Date('2026-08-06T18:00:00-07:00').getTime();
-// Spago posted off-schedule first, so restaurants start one ahead.
-const OFFSET = { restaurant: 1, store: 0 };
+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';
+  const rows = readLedger(path.join(POSTER, 'social/poster', 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');
-const listFor = (k) => (k === 'store' ? stores : restaurants);
 
-// All slot datetimes in [from, to], chronological.
+// 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);
+  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);
-      const ms = t.getTime();
-      if (ms >= from && ms <= to) out.push({ ms, hour: h });
+      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);
 }
 
-// Count same-kind slots strictly between ANCHOR and ms (to derive the rotation index).
-function kindIndexAt(ms, kind) {
-  if (ms < ANCHOR) return 0;
-  let n = 0;
-  for (const s of slotsBetween(ANCHOR, ms - 1)) if (kindForHour(s.hour) === kind) n++;
-  return n;
-}
-
 export function upcoming({ now = Date.now(), days = 14 } = {}) {
-  const to = now + days * 864e5;
+  const lists = { restaurant: films('restaurant'), store: films('store') };
+  const seeds = { restaurant: ledgerSeed('restaurant'), store: ledgerSeed('store') };
   const items = [];
-  for (const s of slotsBetween(now, to)) {
+  for (const s of slotsBetween(now, now + days * 864e5)) {
     const kind = kindForHour(s.hour);
-    const list = listFor(kind);
-    const idx = (kindIndexAt(s.ms, kind) + OFFSET[kind]) % list.length;
-    const film = list[idx];
+    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 === 'restaurant' ? 'Restaurant' : 'Store'),
-      slug: film.slug,
-      src: film.src,
+      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: restaurants.length, stores: stores.length, slotsPerDay: SLOT_HOURS.length };
+  return { restaurants: films('restaurant').length, stores: films('store').length, slotsPerDay: SLOT_HOURS.length };
 }

← 49bf8b9 calendar-agentabrams: BHV posting calendar (schedule engine  ·  back to Calendar Agentabrams  ·  auto-data-snapshot: 2026-08-06T16:33:58 (2 data files) — dat a5efb54 →