[object Object]

← back to Norma Platform

instagram: per-account 'what to post next from DW Shopify' notes board (/notes)

965f96dd0fb4ec2c49bb2638323044c35d8c3a60 · 2026-08-18 14:56:20 -0700 · Steve Abrams

Human-authored posting plan per Instagram account. New account-notes.js (store +
GET/POST /api/live/account-notes + GET /notes board) and notes-viewer.html — a
35-account grid with an auto-saving note textarea each, filter (all/with/without),
search, per-card updated date+time stamp, and deep-links to that account's posts.
Notes stored in data/account-notes.json (separate from the media caches so a refresh
sweep never clobbers them). Cross-linked from /live + /calendar.
Verified: read/save/clear round-trip, unknown-account 400, UI auto-save persists,
board renders 35 cards no errors, public tunnel gated.

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

Files touched

Diff

commit 965f96dd0fb4ec2c49bb2638323044c35d8c3a60
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 14:56:20 2026 -0700

    instagram: per-account 'what to post next from DW Shopify' notes board (/notes)
    
    Human-authored posting plan per Instagram account. New account-notes.js (store +
    GET/POST /api/live/account-notes + GET /notes board) and notes-viewer.html — a
    35-account grid with an auto-saving note textarea each, filter (all/with/without),
    search, per-card updated date+time stamp, and deep-links to that account's posts.
    Notes stored in data/account-notes.json (separate from the media caches so a refresh
    sweep never clobbers them). Cross-linked from /live + /calendar.
    Verified: read/save/clear round-trip, unknown-account 400, UI auto-save persists,
    board renders 35 cards no errors, public tunnel gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 agents/instagram-agent/account-notes.js            |  82 +++++++++++
 agents/instagram-agent/public/calendar-viewer.html |   1 +
 agents/instagram-agent/public/live-viewer.html     |   1 +
 agents/instagram-agent/public/notes-viewer.html    | 151 +++++++++++++++++++++
 agents/instagram-agent/server.js                   |   4 +
 5 files changed, 239 insertions(+)

diff --git a/agents/instagram-agent/account-notes.js b/agents/instagram-agent/account-notes.js
new file mode 100644
index 0000000..c63b666
--- /dev/null
+++ b/agents/instagram-agent/account-notes.js
@@ -0,0 +1,82 @@
+/**
+ * account-notes.js — per-account "what to post next from DW Shopify" planning notes for
+ * norma-instagram (:9810).
+ *
+ * Lets Steve attach a free-text brief to EACH of the 35 Instagram accounts telling that account
+ * what to post next from the DW Shopify store (product handles, collections, themes, timing…).
+ * The notes are a human-authored PLAN, read by whoever composes the next posts.
+ *
+ * Storage: a SINGLE data/account-notes.json keyed by ig_user_id — deliberately NOT stored inside
+ * the per-account live-media caches, because those get overwritten wholesale by every refresh
+ * sweep (which would clobber the notes). Keeping the plan in its own file makes it survive refreshes.
+ *
+ * Security: only accounts present in the roster (accounts.json + env default) can be written — an
+ * arbitrary ig_user_id is rejected, matching live-media.js.
+ *
+ * Routes:
+ *   GET  /notes                                  → planning board (public/notes-viewer.html)
+ *   GET  /api/live/account-notes[?account=<id>]  → all notes (or one)
+ *   POST /api/live/account-notes {account, note} → save/overwrite one account's note
+ */
+const fs = require('fs');
+const path = require('path');
+const { listAccounts } = require('./live-media');
+
+const DATA = path.join(__dirname, 'data');
+const NOTES_FILE = path.join(DATA, 'account-notes.json');
+const VIEWER = path.join(__dirname, 'public', 'notes-viewer.html');
+
+function readNotes() {
+  try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; }
+}
+function writeNotes(obj) {
+  try { fs.mkdirSync(DATA, { recursive: true }); } catch { /* ignore */ }
+  fs.writeFileSync(NOTES_FILE, JSON.stringify(obj, null, 2));
+}
+
+function rosterIds() {
+  const s = new Set();
+  for (const a of listAccounts()) s.add(a.ig_user_id);
+  return s;
+}
+
+function registerNotesRoutes(app) {
+  app.get('/notes', (req, res) => {
+    if (!fs.existsSync(VIEWER)) return res.status(404).send('notes-viewer.html missing');
+    res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
+  });
+
+  // Read: all notes, or one account's, joined onto the roster so the board can render every account.
+  app.get('/api/live/account-notes', (req, res) => {
+    try {
+      const notes = readNotes();
+      if (req.query.account) {
+        const id = String(req.query.account);
+        return res.json({ account: id, ...(notes[id] || { note: '', updated_at: null }) });
+      }
+      const accounts = listAccounts().map((a) => ({
+        handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name || '',
+        note: (notes[a.ig_user_id] && notes[a.ig_user_id].note) || '',
+        updated_at: (notes[a.ig_user_id] && notes[a.ig_user_id].updated_at) || null,
+      }));
+      res.json({ accounts });
+    } catch (e) { res.status(500).json({ error: e.message }); }
+  });
+
+  // Save: overwrite one account's note. Reversible — it's a single keyed value; clearing = empty note.
+  app.post('/api/live/account-notes', (req, res) => {
+    try {
+      const b = req.body || {};
+      const id = String(b.account || '');
+      if (!rosterIds().has(id)) return res.status(400).json({ error: 'unknown account (not in roster)' });
+      const note = typeof b.note === 'string' ? b.note.slice(0, 4000) : '';
+      const notes = readNotes();
+      if (note.trim() === '') delete notes[id];              // empty = clear the note
+      else notes[id] = { note, updated_at: new Date().toISOString() };
+      writeNotes(notes);
+      res.json({ ok: true, account: id, note, updated_at: notes[id] ? notes[id].updated_at : null });
+    } catch (e) { res.status(500).json({ error: e.message }); }
+  });
+}
+
+module.exports = { registerNotesRoutes, readNotes };
diff --git a/agents/instagram-agent/public/calendar-viewer.html b/agents/instagram-agent/public/calendar-viewer.html
index 1deff92..87a0e08 100644
--- a/agents/instagram-agent/public/calendar-viewer.html
+++ b/agents/instagram-agent/public/calendar-viewer.html
@@ -103,6 +103,7 @@ main{padding:14px 16px 80px}
     </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>
diff --git a/agents/instagram-agent/public/live-viewer.html b/agents/instagram-agent/public/live-viewer.html
index 0cc1de0..4e970fa 100644
--- a/agents/instagram-agent/public/live-viewer.html
+++ b/agents/instagram-agent/public/live-viewer.html
@@ -60,6 +60,7 @@ main{padding:14px 16px 80px}
     <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>
+    <a class="btn" href="/notes" title="Per-account plan: what to post next from DW Shopify">📝 Notes</a>
   </div>
   <div class="row1">
     <input id="q" placeholder="Search caption…" style="min-width:180px">
diff --git a/agents/instagram-agent/public/notes-viewer.html b/agents/instagram-agent/public/notes-viewer.html
new file mode 100644
index 0000000..acc0506
--- /dev/null
+++ b/agents/instagram-agent/public/notes-viewer.html
@@ -0,0 +1,151 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Instagram — What to Post Next (per account)</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;--card:340px;
+}
+*{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:20;background:var(--s);border-bottom:1px solid var(--bd);padding:12px 16px;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,textarea,select{font-family:inherit}
+input{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 10px;font-size:13px;min-width:200px}
+input:focus{outline:none;border-color:var(--p)}
+.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)}
+.seg{display:inline-flex;border:1px solid var(--bd);border-radius:8px;overflow:hidden}
+.seg button{background:var(--e);color:var(--m);border:0;padding:7px 11px;font-size:12.5px;cursor:pointer}
+.seg button.on{background:var(--p);color:#04120c;font-weight:650}
+main{padding:14px 16px 80px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:14px}
+.card{background:var(--s);border:1px solid var(--bd);border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:9px}
+.card.hide{display:none}
+.chead{display:flex;align-items:center;gap:8px}
+.dot{width:12px;height:12px;border-radius:50%;flex:none}
+.h{font-weight:650;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.pn{color:var(--mut);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.card textarea{width:100%;min-height:104px;resize:vertical;background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:9px;padding:9px 10px;font-size:13px;line-height:1.5}
+.card textarea:focus{outline:none;border-color:var(--p)}
+.card textarea.dirty{border-color:var(--amber)}
+.foot{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--mut);min-height:16px}
+.when{display:flex;align-items:center;gap:5px}
+.status{margin-left:auto}
+.status.saving{color:var(--amber)}
+.status.saved{color:var(--p)}
+.status.err{color:var(--r)}
+.card .lv{color:var(--m);font-size:11.5px}
+.filled .h::after{content:"📝";font-size:11px;margin-left:6px}
+.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:50;display:none}
+.toast.show{display:block}.toast.err{border-color:var(--r);color:#ffd7de}
+</style></head>
+<body>
+<header>
+  <h1>📝 What to Post Next <span class="sub">— per account, from DW Shopify</span></h1>
+  <input id="q" placeholder="Filter accounts…">
+  <div class="seg" id="filt">
+    <button data-f="all" class="on">All</button>
+    <button data-f="with">With notes</button>
+    <button data-f="without">Empty</button>
+  </div>
+  <span class="grow"></span>
+  <span class="sub" id="summary">loading…</span>
+  <a class="btn" href="/calendar">📅 Calendar</a>
+  <a class="btn" href="/live">📸 List</a>
+</header>
+
+<main><div class="grid" id="grid"></div></main>
+<div class="toast" id="toast"></div>
+
+<script>
+const $=(s)=>document.querySelector(s);
+let ACCOUNTS=[];
+function color(i){ return `hsl(${Math.round((i*137.508)%360)} 66% 60%)`; }
+function toast(m,err){ const t=$('#toast'); t.textContent=m; t.className='toast show'+(err?' err':''); clearTimeout(t._h); t._h=setTimeout(()=>t.className='toast',2400); }
+function fmtWhen(iso){
+  if(!iso) return 'never edited';
+  return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+}
+
+async function load(){
+  let d;
+  try{ d=await (await fetch('/api/live/account-notes')).json(); if(d.error) throw new Error(d.error); }
+  catch(e){ toast('Load failed: '+e.message,true); return; }
+  ACCOUNTS=d.accounts.map((a,i)=>({...a,color:color(i)}));
+  render();
+  updateSummary();
+}
+function updateSummary(){
+  const withN=ACCOUNTS.filter(a=>a.note && a.note.trim()).length;
+  $('#summary').textContent=`${withN}/${ACCOUNTS.length} accounts have a post plan`;
+}
+
+function render(){
+  const grid=$('#grid'); grid.innerHTML='';
+  for(const a of ACCOUNTS){
+    const card=document.createElement('div');
+    card.className='card'+((a.note&&a.note.trim())?' filled':'');
+    card.dataset.handle=a.handle.toLowerCase();
+    card.dataset.has=(a.note&&a.note.trim())?'1':'0';
+    card.innerHTML=`
+      <div class="chead">
+        <span class="dot" style="background:${a.color}"></span>
+        <span class="h">@${a.handle}</span>
+        <a class="lv" href="/live?account=${a.ig_user_id}" title="Open this account's posts" style="margin-left:auto">posts ↗</a>
+      </div>
+      ${a.page_name?`<div class="pn">${a.page_name}</div>`:''}
+      <textarea placeholder="What should @${a.handle} post next from the DW Shopify store? e.g. product handles, a collection, a theme, timing…">${(a.note||'').replace(/</g,'&lt;')}</textarea>
+      <div class="foot">
+        <span class="when">🕓 <span class="wt">${fmtWhen(a.updated_at)}</span></span>
+        <span class="status"></span>
+      </div>`;
+    const ta=card.querySelector('textarea');
+    const status=card.querySelector('.status');
+    const wt=card.querySelector('.wt');
+    let saved=a.note||'';
+    let timer=null;
+    const save=async()=>{
+      const val=ta.value;
+      if(val===saved){ ta.classList.remove('dirty'); status.className='status'; status.textContent=''; return; }
+      status.className='status saving'; status.textContent='saving…';
+      try{
+        const r=await fetch('/api/live/account-notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:a.ig_user_id,note:val})});
+        const j=await r.json(); if(!r.ok||j.error) throw new Error(j.error||('HTTP '+r.status));
+        saved=val; a.note=val; a.updated_at=j.updated_at;
+        ta.classList.remove('dirty');
+        status.className='status saved'; status.textContent='✓ saved';
+        wt.textContent=fmtWhen(j.updated_at);
+        card.classList.toggle('filled', !!(val&&val.trim()));
+        card.dataset.has=(val&&val.trim())?'1':'0';
+        updateSummary(); applyFilter();
+        setTimeout(()=>{ if(status.textContent==='✓ saved'){ status.className='status'; status.textContent=''; } },1500);
+      }catch(e){ status.className='status err'; status.textContent='save failed'; toast('Save failed: '+e.message,true); }
+    };
+    ta.addEventListener('input',()=>{ ta.classList.toggle('dirty', ta.value!==saved); clearTimeout(timer); timer=setTimeout(save,900); });
+    ta.addEventListener('blur',()=>{ clearTimeout(timer); save(); });
+    grid.appendChild(card);
+  }
+  applyFilter();
+}
+
+let curFilter='all';
+function applyFilter(){
+  const q=($('#q').value||'').toLowerCase().trim();
+  document.querySelectorAll('.card').forEach(c=>{
+    const matchQ=!q||c.dataset.handle.includes(q);
+    const has=c.dataset.has==='1';
+    const matchF=curFilter==='all'||(curFilter==='with'&&has)||(curFilter==='without'&&!has);
+    c.classList.toggle('hide',!(matchQ&&matchF));
+  });
+}
+$('#q').addEventListener('input',applyFilter);
+$('#filt').addEventListener('click',(e)=>{ const b=e.target.closest('button'); if(!b)return;
+  document.querySelectorAll('#filt button').forEach(x=>x.classList.remove('on')); b.classList.add('on'); curFilter=b.dataset.f; applyFilter(); });
+
+load();
+</script>
+</body></html>
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index f8ad39d..de16c20 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -220,4 +220,8 @@ require('./live-media').registerLiveRoutes(app, AGENT_NAME);
 // posts grouped by calendar day, with an all/select-accounts filter. Read-only over the same caches.
 require('./calendar').registerCalendarRoutes(app);
 
+// Per-account "what to post next from DW Shopify" planning notes (GET /notes). Human-authored brief
+// per account, stored separately from the refreshed caches so a refresh never clobbers it.
+require('./account-notes').registerNotesRoutes(app);
+
 start();

← f85f258 auto-data-snapshot: 2026-08-18T14:32:35 (1 data files) — age  ·  back to Norma Platform  ·  auto-data-snapshot: 2026-08-18T15:04:50 (1 data files) — age 6512f67 →