← back to Norma Platform
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
A agents/instagram-agent/calendar.jsM agents/instagram-agent/live-media.jsA agents/instagram-agent/public/calendar-viewer.htmlM agents/instagram-agent/public/live-viewer.htmlM agents/instagram-agent/server.js
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,'<').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 Platform
·
instagram: keep ALL accounts live via cron (not just DW) 8cfcfe2 →