← back to Norma

agents/instagram-agent/public/calendar-viewer.html

302 lines

<!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="/notes" title="Per-account plan: what to post next from DW Shopify">📝 Notes</a>
    <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>