← back to Norma
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
A agents/instagram-agent/account-notes.jsM agents/instagram-agent/public/calendar-viewer.htmlM agents/instagram-agent/public/live-viewer.htmlA agents/instagram-agent/public/notes-viewer.htmlM agents/instagram-agent/server.js
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,'<')}</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
·
auto-data-snapshot: 2026-08-18T15:04:50 (1 data files) — age 6512f67 →