[object Object]

← back to Norma

instagram: cross-account post Calendar view (/calendar)

835c032acf12b754f379439356143c51379209a3 · 2026-08-18 14:22:35 -0700 · Steve Abrams

Day-major transpose of the /live board: a month calendar of what posted each
day across all 35 IG accounts, with an all/select-accounts filter (color-dotted,
localStorage-persisted), per-day count pills + thumbnails, and a click-through
day drawer listing that day's posts across accounts (account chip, time, caption,
stats, permalink). Read-only over the same per-account caches; no Graph API call
on page view. New: calendar.js (monthView/dayView + routes), calendar-viewer.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 835c032acf12b754f379439356143c51379209a3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 14:22:35 2026 -0700

    instagram: cross-account post Calendar view (/calendar)
    
    Day-major transpose of the /live board: a month calendar of what posted each
    day across all 35 IG accounts, with an all/select-accounts filter (color-dotted,
    localStorage-persisted), per-day count pills + thumbnails, and a click-through
    day drawer listing that day's posts across accounts (account chip, time, caption,
    stats, permalink). Read-only over the same per-account caches; no Graph API call
    on page view. New: calendar.js (monthView/dayView + routes), calendar-viewer.html.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 agents/instagram-agent/calendar.js                 | 153 +++++++++++
 agents/instagram-agent/live-media.js               |   2 +-
 agents/instagram-agent/public/calendar-viewer.html | 300 +++++++++++++++++++++
 agents/instagram-agent/public/live-viewer.html     |   1 +
 agents/instagram-agent/server.js                   |   4 +
 5 files changed, 459 insertions(+), 1 deletion(-)

diff --git a/agents/instagram-agent/calendar.js b/agents/instagram-agent/calendar.js
new file mode 100644
index 0000000..46dad05
--- /dev/null
+++ b/agents/instagram-agent/calendar.js
@@ -0,0 +1,153 @@
+/**
+ * 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');
+
+// 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 < 8 && 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 };
diff --git a/agents/instagram-agent/live-media.js b/agents/instagram-agent/live-media.js
index 6a92100..e127001 100644
--- a/agents/instagram-agent/live-media.js
+++ b/agents/instagram-agent/live-media.js
@@ -243,4 +243,4 @@ function registerLiveRoutes(app, agentName = 'instagram-agent') {
   });
 }
 
-module.exports = { registerLiveRoutes, refreshLiveMedia, getLivePosts, listAccounts };
+module.exports = { registerLiveRoutes, refreshLiveMedia, getLivePosts, listAccounts, readCache, readTombstones };
diff --git a/agents/instagram-agent/public/calendar-viewer.html b/agents/instagram-agent/public/calendar-viewer.html
new file mode 100644
index 0000000..1deff92
--- /dev/null
+++ b/agents/instagram-agent/public/calendar-viewer.html
@@ -0,0 +1,300 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Instagram — Post Calendar (all accounts)</title>
+<style>
+:root{
+  --bg:#0c0f1a;--s:#131829;--e:#1c2237;--t:#f0f0f5;--m:#a1a1b5;--mut:#6b7280;
+  --p:#10b981;--ph:#059669;--r:#f43f5e;--amber:#f59e0b;--bd:#2a3048;
+  --cell:96px;
+}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--t);font:15px/1.45 -apple-system,system-ui,Segoe UI,sans-serif}
+a{color:var(--p);text-decoration:none}
+header{position:sticky;top:0;z-index:30;background:var(--s);border-bottom:1px solid var(--bd);padding:12px 16px;display:flex;flex-direction:column;gap:10px}
+.row1{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:16px;margin:0;font-weight:650;display:flex;gap:8px;align-items:center}
+.sub{color:var(--m);font-size:12.5px}
+.grow{flex:1}
+button,input,select{font-family:inherit}
+.btn{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 12px;font-size:12.5px;cursor:pointer}
+.btn:hover{border-color:var(--p)}
+.btn.primary{background:var(--p);color:#04120c;border-color:var(--p);font-weight:650}
+.mnav{display:flex;align-items:center;gap:8px}
+.mnav .lbl{font-size:15px;font-weight:650;min-width:150px;text-align:center}
+.range{display:flex;align-items:center;gap:6px;color:var(--m);font-size:12px}
+input[type=range]{accent-color:var(--p)}
+
+/* account filter dropdown */
+.dd{position:relative}
+.dd-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:40;background:var(--s);border:1px solid var(--bd);border-radius:10px;
+  padding:8px;min-width:280px;max-height:60vh;overflow:auto;display:none;box-shadow:0 12px 40px #000a}
+.dd-panel.show{display:block}
+.dd-panel .allrow{display:flex;gap:8px;align-items:center;padding:7px 8px;border-bottom:1px solid var(--bd);margin-bottom:4px}
+.acc{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:7px;cursor:pointer;font-size:13px}
+.acc:hover{background:var(--e)}
+.acc input{margin:0}
+.dot{width:10px;height:10px;border-radius:50%;flex:none}
+.acc .h{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.acc .n{color:var(--mut);font-size:11.5px}
+
+/* calendar grid */
+main{padding:14px 16px 80px}
+.wk{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;margin-bottom:8px}
+.wk .wd{color:var(--m);font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;text-align:center}
+.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}
+.cell{background:var(--s);border:1px solid var(--bd);border-radius:10px;min-height:var(--cell);padding:7px;display:flex;flex-direction:column;gap:5px;position:relative;cursor:default;transition:border-color .15s}
+.cell.blank{background:transparent;border:0}
+.cell.has{cursor:pointer}
+.cell.has:hover{border-color:var(--p)}
+.cell.today{border-color:var(--amber)}
+.cell .dnum{font-size:12px;color:var(--m);display:flex;align-items:center;gap:6px}
+.cell.today .dnum{color:var(--amber);font-weight:700}
+.cell .cnt{margin-left:auto;background:var(--p);color:#04120c;font-weight:700;font-size:11px;border-radius:20px;padding:1px 7px}
+.cell .cnt.zero{display:none}
+.dots{display:flex;flex-wrap:wrap;gap:3px}
+.thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;margin-top:auto}
+.thumbs .tw{aspect-ratio:1;border-radius:4px;background:#0a0d17 center/cover no-repeat;border:1px solid #0006}
+.empt{color:var(--mut);font-size:11px;margin-top:auto}
+
+/* day drawer */
+.scrim{position:fixed;inset:0;background:#000a;z-index:45;display:none}
+.scrim.show{display:block}
+.drawer{position:fixed;top:0;right:0;height:100vh;width:min(560px,94vw);background:var(--bg);border-left:1px solid var(--bd);
+  z-index:46;transform:translateX(100%);transition:transform .2s;display:flex;flex-direction:column}
+.drawer.show{transform:none}
+.drawer header{position:sticky;top:0}
+.dhead{display:flex;align-items:center;gap:10px}
+.dhead h2{font-size:15px;margin:0}
+.dbody{padding:14px 16px 40px;overflow:auto}
+.pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--pcard,180px),1fr));gap:12px}
+.pc{background:var(--s);border:1px solid var(--bd);border-radius:11px;overflow:hidden;display:flex;flex-direction:column}
+.pc .th{aspect-ratio:1;background:#0a0d17 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:11px;position:relative}
+.pc .acc-chip{position:absolute;top:7px;left:7px;background:#000b;color:#fff;font-size:10.5px;padding:2px 7px;border-radius:20px;display:flex;align-items:center;gap:5px;max-width:88%}
+.pc .acc-chip .dot{width:8px;height:8px}
+.pc .acc-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.pc .badge{position:absolute;top:7px;right:7px;background:#000a;color:#fff;font-size:10px;padding:2px 6px;border-radius:20px;text-transform:capitalize}
+.pc .mt{padding:8px 10px 10px;display:flex;flex-direction:column;gap:5px;flex:1}
+.pc .tm{font-size:11px;color:var(--amber)}
+.pc .cap{font-size:12px;color:var(--m);max-height:3.2em;overflow:hidden}
+.pc .st{font-size:11px;color:var(--mut);display:flex;gap:10px;margin-top:auto}
+.pc .st a{margin-left:auto;color:var(--m)}
+.dsum{color:var(--m);font-size:12.5px;margin:0 0 12px;display:flex;flex-wrap:wrap;gap:6px}
+.chip{background:var(--e);border:1px solid var(--bd);border-radius:20px;padding:2px 9px;font-size:11.5px;display:inline-flex;gap:5px;align-items:center}
+.empty{color:var(--m);text-align:center;padding:60px 0}
+.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--e);border:1px solid var(--bd);border-radius:10px;padding:10px 16px;font-size:13px;z-index:60;display:none}
+.toast.show{display:block}.toast.err{border-color:var(--r);color:#ffd7de}
+</style></head>
+<body>
+<header>
+  <div class="row1">
+    <h1>📅 Post Calendar</h1>
+    <div class="mnav">
+      <button class="btn" id="prev" title="Previous month">‹</button>
+      <span class="lbl" id="mlabel">…</span>
+      <button class="btn" id="next" title="Next month">›</button>
+      <button class="btn" id="today" title="Jump to current month">Today</button>
+    </div>
+    <div class="dd">
+      <button class="btn" id="accBtn">Accounts: All ▾</button>
+      <div class="dd-panel" id="accPanel">
+        <label class="allrow acc"><input type="checkbox" id="accAll"><b>All accounts</b><span class="grow"></span><span class="n" id="accAllN"></span></label>
+        <div id="accList"></div>
+      </div>
+    </div>
+    <span class="grow"></span>
+    <span class="sub" id="summary">loading…</span>
+    <a class="btn" href="/live" title="Switch to the per-account list view">📸 List view</a>
+  </div>
+</header>
+
+<main>
+  <div class="wk" id="weekhead"></div>
+  <div class="cal" id="cal"></div>
+</main>
+
+<!-- day drawer -->
+<div class="scrim" id="scrim"></div>
+<aside class="drawer" id="drawer" aria-hidden="true">
+  <header>
+    <div class="dhead">
+      <h2 id="ddate">Day</h2>
+      <span class="grow"></span>
+      <div class="range" title="Card size"><span>▦</span><input type="range" id="dens" min="120" max="280" step="20" value="180"><span>▤</span></div>
+      <button class="btn" id="dclose">✕</button>
+    </div>
+  </header>
+  <div class="dbody">
+    <p class="dsum" id="dsum"></p>
+    <div class="pgrid" id="pgrid"></div>
+  </div>
+</aside>
+
+<div class="toast" id="toast"></div>
+
+<script>
+const $ = (s)=>document.querySelector(s);
+const state = {
+  month: null,                 // 'YYYY-MM'
+  accounts: [],                // roster [{handle,ig_user_id,page_name,color,month_count}]
+  selected: load('cal.sel', 'all'),   // 'all' | [ids]
+  data: null,
+};
+function load(k,def){ try{ const v=JSON.parse(localStorage.getItem(k)); return v==null?def:v; }catch{ return def; } }
+function save(k,v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch{} }
+function toast(msg,err){ const t=$('#toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); clearTimeout(t._h); t._h=setTimeout(()=>t.className='toast',2600); }
+
+function curMonth(){ const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); }
+function accParam(){ return (state.selected==='all')?'all':(Array.isArray(state.selected)?state.selected.join(','):'all'); }
+
+function monthLabel(m){ const [y,mo]=m.split('-').map(Number); return new Date(y,mo-1,1).toLocaleDateString(undefined,{month:'long',year:'numeric'}); }
+function shiftMonth(m,delta){ const [y,mo]=m.split('-').map(Number); const d=new Date(y,mo-1+delta,1); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); }
+
+async function loadMonth(){
+  state.month = state.month || curMonth();
+  $('#mlabel').textContent = monthLabel(state.month);
+  $('#summary').textContent = 'loading…';
+  let d;
+  try{
+    const r = await fetch(`/api/live/calendar?month=${state.month}&accounts=${encodeURIComponent(accParam())}`);
+    d = await r.json();
+    if(d.error) throw new Error(d.error);
+  }catch(e){ toast('Load failed: '+e.message,true); return; }
+  state.data = d;
+  state.accounts = d.accounts;
+  renderAccountPanel();
+  renderCalendar();
+  const sel = (state.selected==='all')? state.accounts.length : (Array.isArray(state.selected)?state.selected.length:state.accounts.length);
+  const asOf = d.cached_at ? new Date(d.cached_at).toLocaleString() : '—';
+  let s = `${d.month_count.toLocaleString()} posts · ${d.active_days} active day${d.active_days===1?'':'s'} · ${sel}/${state.accounts.length} accounts · as of ${asOf}`;
+  if(d.incomplete_accounts && d.incomplete_accounts.length) s += `  ·  ⚠ ${d.incomplete_accounts.length} account(s) not fully backfilled`;
+  $('#summary').textContent = s;
+  updateAccBtn();
+}
+
+function selectedSet(){
+  if(state.selected==='all') return new Set(state.accounts.map(a=>a.ig_user_id));
+  return new Set(Array.isArray(state.selected)?state.selected:[]);
+}
+function isSel(id){ return state.selected==='all' || (Array.isArray(state.selected)&&state.selected.includes(id)); }
+
+function updateAccBtn(){
+  const total=state.accounts.length;
+  const n = state.selected==='all'? total : (Array.isArray(state.selected)?state.selected.length:total);
+  $('#accBtn').textContent = `Accounts: ${n===total?'All':n} ▾`;
+}
+
+function renderAccountPanel(){
+  $('#accAll').checked = (state.selected==='all') || (Array.isArray(state.selected)&&state.selected.length===state.accounts.length);
+  $('#accAllN').textContent = `${state.accounts.length} accounts`;
+  const list = $('#accList');
+  list.innerHTML='';
+  for(const a of state.accounts){
+    const lab=document.createElement('label'); lab.className='acc';
+    lab.innerHTML=`<input type="checkbox" ${isSel(a.ig_user_id)?'checked':''} data-id="${a.ig_user_id}">
+      <span class="dot" style="background:${a.color}"></span>
+      <span class="h">@${a.handle}</span>
+      <span class="n">${a.month_count||0}</span>`;
+    lab.querySelector('input').addEventListener('change',(e)=>toggleAccount(a.ig_user_id, e.target.checked));
+    list.appendChild(lab);
+  }
+}
+function toggleAccount(id,on){
+  let cur = state.selected==='all' ? state.accounts.map(a=>a.ig_user_id) : [...(Array.isArray(state.selected)?state.selected:[])];
+  if(on){ if(!cur.includes(id)) cur.push(id); } else { cur = cur.filter(x=>x!==id); }
+  state.selected = (cur.length===state.accounts.length)? 'all' : cur;
+  save('cal.sel', state.selected);
+  loadMonth();
+}
+$('#accAll').addEventListener('change',(e)=>{
+  state.selected = e.target.checked ? 'all' : [];
+  save('cal.sel', state.selected);
+  loadMonth();
+});
+$('#accBtn').addEventListener('click',()=>$('#accPanel').classList.toggle('show'));
+document.addEventListener('click',(e)=>{ if(!e.target.closest('.dd')) $('#accPanel').classList.remove('show'); });
+
+function renderWeekhead(){
+  const wd=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+  $('#weekhead').innerHTML = wd.map(w=>`<div class="wd">${w}</div>`).join('');
+}
+
+function renderCalendar(){
+  const [y,mo]=state.month.split('-').map(Number);
+  const first=new Date(y,mo-1,1);
+  const startDow=first.getDay();
+  const daysIn=new Date(y,mo,0).getDate();
+  const todayKey=(()=>{ const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); })();
+  const cal=$('#cal'); cal.innerHTML='';
+  for(let i=0;i<startDow;i++){ const b=document.createElement('div'); b.className='cell blank'; cal.appendChild(b); }
+  for(let day=1;day<=daysIn;day++){
+    const key=`${state.month}-${String(day).padStart(2,'0')}`;
+    const info=state.data.days[key];
+    const cell=document.createElement('div');
+    cell.className='cell'+(info?' has':'')+(key===todayKey?' today':'');
+    const cnt=info?info.count:0;
+    const dots = info ? Object.keys(info.byAccount).slice(0,10).map(id=>{
+      const a=state.accounts.find(x=>x.ig_user_id===id); return a?`<span class="dot" style="background:${a.color}" title="@${a.handle}: ${info.byAccount[id]}"></span>`:'';
+    }).join('') : '';
+    const thumbs = info ? `<div class="thumbs">${info.thumbs.slice(0,4).map(t=>`<div class="tw" style="background-image:url('${t.thumb}')" title="@${t.handle}"></div>`).join('')}</div>` : '<div class="empt">—</div>';
+    cell.innerHTML=`<div class="dnum">${day}<span class="cnt ${cnt?'':'zero'}">${cnt}</span></div>
+      <div class="dots">${dots}</div>${thumbs}`;
+    if(info) cell.addEventListener('click',()=>openDay(key));
+    cal.appendChild(cell);
+  }
+}
+
+// ── day drawer ──
+async function openDay(date){
+  $('#scrim').classList.add('show');
+  $('#drawer').classList.add('show');
+  $('#drawer').setAttribute('aria-hidden','false');
+  $('#ddate').textContent = new Date(date+'T12:00:00').toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric',year:'numeric'});
+  $('#dsum').textContent='loading…'; $('#pgrid').innerHTML='';
+  let d;
+  try{
+    const r=await fetch(`/api/live/calendar/day?date=${date}&accounts=${encodeURIComponent(accParam())}`);
+    d=await r.json(); if(d.error) throw new Error(d.error);
+  }catch(e){ $('#dsum').textContent=''; toast('Day load failed: '+e.message,true); return; }
+  const chips=Object.entries(d.byAccount).sort((a,b)=>b[1]-a[1]).map(([h,n])=>{
+    const a=state.accounts.find(x=>x.handle===h); const col=a?a.color:'#888';
+    return `<span class="chip"><span class="dot" style="background:${col}"></span>@${h} · ${n}</span>`;
+  }).join('');
+  $('#dsum').innerHTML=`<b>${d.count}</b> post${d.count===1?'':'s'} across ${Object.keys(d.byAccount).length} account(s) ${chips}`;
+  const g=$('#pgrid');
+  if(!d.count){ g.innerHTML='<div class="empty">No posts this day for the selected accounts.</div>'; return; }
+  g.innerHTML = d.posts.map(p=>{
+    const tm=p.ts?new Date(p.ts).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):'';
+    const cap=(p.caption||'').replace(/</g,'&lt;').slice(0,140);
+    const th=p.thumb?`style="background-image:url('${p.thumb}')"`:'';
+    return `<div class="pc">
+      <div class="th" ${th}>${p.thumb?'':'no image'}
+        <span class="acc-chip"><span class="dot" style="background:${p.color}"></span><span>@${p.handle}</span></span>
+        <span class="badge">${(p.type||'').toLowerCase()}</span>
+      </div>
+      <div class="mt">
+        <div class="tm">${tm}</div>
+        <div class="cap">${cap}</div>
+        <div class="st"><span>♥ ${p.likes??'—'}</span><span>💬 ${p.comments??'—'}</span>${p.permalink?`<a href="${p.permalink}" target="_blank" rel="noopener noreferrer">open ↗</a>`:''}</div>
+      </div></div>`;
+  }).join('');
+}
+function closeDay(){ $('#scrim').classList.remove('show'); $('#drawer').classList.remove('show'); $('#drawer').setAttribute('aria-hidden','true'); }
+$('#scrim').addEventListener('click',closeDay);
+$('#dclose').addEventListener('click',closeDay);
+document.addEventListener('keydown',(e)=>{ if(e.key==='Escape') closeDay(); });
+$('#dens').addEventListener('input',(e)=>{ document.documentElement.style.setProperty('--pcard', e.target.value+'px'); save('cal.dens', e.target.value); });
+
+// month nav
+$('#prev').addEventListener('click',()=>{ state.month=shiftMonth(state.month,-1); loadMonth(); });
+$('#next').addEventListener('click',()=>{ state.month=shiftMonth(state.month,1); loadMonth(); });
+$('#today').addEventListener('click',()=>{ state.month=curMonth(); loadMonth(); });
+
+// boot
+(function init(){
+  const dens=load('cal.dens',180); document.documentElement.style.setProperty('--pcard',dens+'px'); $('#dens').value=dens;
+  renderWeekhead();
+  state.month=curMonth();
+  loadMonth();
+})();
+</script>
+</body></html>
diff --git a/agents/instagram-agent/public/live-viewer.html b/agents/instagram-agent/public/live-viewer.html
index a9da149..0cc1de0 100644
--- a/agents/instagram-agent/public/live-viewer.html
+++ b/agents/instagram-agent/public/live-viewer.html
@@ -59,6 +59,7 @@ main{padding:14px 16px 80px}
     <span class="grow"></span>
     <button class="btn" id="refresh" title="Fetch new posts since last cache">↻ Refresh</button>
     <button class="btn" id="backfill" title="Paginate the entire post history into the cache">⤓ Backfill all</button>
+    <a class="btn" href="/calendar" title="See a month calendar of posts across all accounts">📅 Calendar</a>
   </div>
   <div class="row1">
     <input id="q" placeholder="Search caption…" style="min-width:180px">
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index 9488053..454e9f1 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -198,4 +198,8 @@ require('./spoonflower-api').registerSpoonflowerRoutes(app);
 // Page views read the cache only; a cron + the Refresh button paginate /media into it.
 require('./live-media').registerLiveRoutes(app, AGENT_NAME);
 
+// Cross-account post CALENDAR (GET /calendar) — the day-major transpose of /live: every account's
+// posts grouped by calendar day, with an all/select-accounts filter. Read-only over the same caches.
+require('./calendar').registerCalendarRoutes(app);
+
 start();

← d987feb auto-data-snapshot: 2026-08-18T13:28:17 (1 data files) — age  ·  back to Norma  ·  instagram: keep ALL accounts live via cron (not just DW) 8cfcfe2 →