[object Object]

← back to Marketing Command Center

MCC Clients: server-side notes store so notes sync across devices

b69f993e10820a676af4ab4e60b18ed237354f6a · 2026-07-17 08:56:37 -0700 · Steve

- /api/clients-notes GET+POST in server.js (atomic JSON store at
  data/clients-notes.json), mounted directly (not via registry) so no phantom
  nav panel; behind the existing basic-auth
- clients.js: pull+merge server notes on group open (server wins, local-only
  notes pushed up), debounced POST on each edit; localStorage stays the instant
  cache. Textarea hints 'syncs across devices'
- protect data/clients-notes.json from rsync --delete on deploy (.deploy.conf
  RSYNC_EXTRA_EXCLUDES + .gitignore)

Files touched

Diff

commit b69f993e10820a676af4ab4e60b18ed237354f6a
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 17 08:56:37 2026 -0700

    MCC Clients: server-side notes store so notes sync across devices
    
    - /api/clients-notes GET+POST in server.js (atomic JSON store at
      data/clients-notes.json), mounted directly (not via registry) so no phantom
      nav panel; behind the existing basic-auth
    - clients.js: pull+merge server notes on group open (server wins, local-only
      notes pushed up), debounced POST on each edit; localStorage stays the instant
      cache. Textarea hints 'syncs across devices'
    - protect data/clients-notes.json from rsync --delete on deploy (.deploy.conf
      RSYNC_EXTRA_EXCLUDES + .gitignore)
---
 .gitignore               |  4 ++++
 public/panels/clients.js | 19 ++++++++++++++++++-
 server.js                | 31 +++++++++++++++++++++++++++++++
 3 files changed, 53 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index f5aa3a2..6e13522 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,10 @@ data/linkedin-drafts.json
 data/ctct-store.json
 data/reposts.json
 
+# Clients & Prospects per-contact notes — accrue server-side per box; never let a
+# deploy clobber them (also excluded from rsync via .deploy.conf RSYNC_EXTRA_EXCLUDES).
+data/clients-notes.json
+
 # Followers/Following runtime state — daily snapshots + roster accrue per box; the
 # snapshot log (.out/.err) is launchd-run state. Code (module + panel) is tracked,
 # the captured history is not (so deploys don't clobber accrued growth data).
diff --git a/public/panels/clients.js b/public/panels/clients.js
index 31e2c59..f1b0000 100644
--- a/public/panels/clients.js
+++ b/public/panels/clients.js
@@ -23,6 +23,11 @@ window.MCC_PANELS['clients'] = {
     const NK = id => 'mcc_cl_notes_' + id;
     const notesOf = id => { try { return JSON.parse(localStorage.getItem(NK(id)) || '{}'); } catch { return {}; } };
     const saveNotes = (id, o) => { try { localStorage.setItem(NK(id), JSON.stringify(o)); } catch {} };
+    // Server-side notes sync so notes follow you across devices; localStorage above
+    // is the instant cache. Writes debounce a POST; opening a group pulls + merges.
+    const noteTimers = {};
+    const notesFetch = async gid => { try { const r = await fetch(ORIGIN + '/api/clients-notes?group=' + encodeURIComponent(gid), { credentials: 'same-origin' }); if (!r.ok) return null; return (await r.json()).notes || {}; } catch { return null; } };
+    const notesPush = (gid, id, text) => { fetch(ORIGIN + '/api/clients-notes', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group: gid, id, text }) }).catch(() => {}); };
     const fmtWhen = ts => { try { return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return ''; } };
 
     // Normalize a record from either a "clients" (FM) or "prospects" (CSV) dataset.
@@ -122,6 +127,14 @@ window.MCC_PANELS['clients'] = {
       }
       const arr = data.clients || data.businesses || [];
       RECS = arr.map(r => norm(r, d.kind));
+      // Pull server-side notes for this group and merge (server wins on conflict;
+      // push any local-only notes up so nothing typed offline is lost).
+      const srvNotes = await notesFetch(d.id);
+      if (srvNotes) {
+        const local = notesOf(d.id);
+        for (const k of Object.keys(local)) if (!(k in srvNotes)) notesPush(d.id, k, local[k]);
+        saveNotes(d.id, Object.assign({}, local, srvNotes));
+      }
       const nEmail = RECS.filter(r => r.email).length, nIg = RECS.filter(r => r._raw.instagram).length, nLi = RECS.filter(r => r._raw.linkedin).length;
       $('#cl-emailcount').textContent = nEmail.toLocaleString() + ' email';
       $('#cl-igcount').textContent = nIg ? nIg.toLocaleString() + ' IG' : '';
@@ -295,7 +308,7 @@ window.MCC_PANELS['clients'] = {
             ${addr}
             <span class="cl-chips" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:5px;">${chips}</span>
             ${bestLine}
-            <textarea class="cl-note${note ? ' has-note' : ''}" data-idx="${i}" rows="1" placeholder="📝 Add a note — call outcome, who to ask for, follow-up date, status…" onclick="event.stopPropagation()">${esc(note)}</textarea>
+            <textarea class="cl-note${note ? ' has-note' : ''}" data-idx="${i}" rows="1" title="Notes sync across your devices" placeholder="📝 Add a note — call outcome, who to ask for, follow-up date, status… (syncs across devices)" onclick="event.stopPropagation()">${esc(note)}</textarea>
           </span>
         </label>`;
       }).join('')}</div>${more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length.toLocaleString()} — type in the filter to narrow.</div>` : ''}</div>`;
@@ -331,6 +344,10 @@ window.MCC_PANELS['clients'] = {
           if (v) O[rec.id] = ta.value; else delete O[rec.id];
           saveNotes(d.id, O);
           ta.classList.toggle('has-note', !!v);
+          // Debounced sync to the server so the note follows the user to any device.
+          const key = d.id + '' + rec.id;
+          clearTimeout(noteTimers[key]);
+          noteTimers[key] = setTimeout(() => notesPush(d.id, rec.id, ta.value), 500);
         });
       });
     }
diff --git a/server.js b/server.js
index bdce7df..cf56b06 100644
--- a/server.js
+++ b/server.js
@@ -76,6 +76,37 @@ for (const id of registry) {
 // ── Shell APIs + static ──────────────────────────────────────────────────────
 app.get('/api/panels', (_req, res) => res.json({ panels }));
 app.get('/api/health', (_req, res) => res.json({ ok: true, panels: panels.length }));
+
+// ── Per-contact notes for the Clients & Prospects panel ──────────────────────
+// Server-side store so notes follow the user across devices (the panel's
+// localStorage is just an instant cache). Not a module — a notes STORE has no
+// nav panel of its own, so it mounts here instead of via the registry.
+// File: data/clients-notes.json  →  { "<groupId>": { "<recId>": { text, updated } } }
+const NOTES_FILE = path.join(__dirname, 'data', 'clients-notes.json');
+const readNotes = () => { try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; } };
+const writeNotes = (obj) => { const tmp = NOTES_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(obj)); fs.renameSync(tmp, NOTES_FILE); };
+app.get('/api/clients-notes', (req, res) => {
+  const group = String(req.query.group || '');
+  if (!group) return res.status(400).json({ error: 'group required' });
+  const g = readNotes()[group] || {};
+  const notes = {};
+  for (const id of Object.keys(g)) notes[id] = (g[id] && g[id].text) || '';
+  res.json({ group, notes });
+});
+app.post('/api/clients-notes', (req, res) => {
+  const { group, id } = req.body || {};
+  let { text } = req.body || {};
+  if (!group || !id || typeof group !== 'string' || typeof id !== 'string') return res.status(400).json({ error: 'group and id required' });
+  if (group.length > 400 || id.length > 400) return res.status(400).json({ error: 'key too long' });
+  text = typeof text === 'string' ? text.slice(0, 8000) : '';
+  const all = readNotes();
+  const g = all[group] || (all[group] = {});
+  if (text.trim()) g[id] = { text, updated: Date.now() };
+  else delete g[id];
+  if (!Object.keys(g).length) delete all[group];
+  try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
+  res.json({ ok: true });
+});
 app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

← 8738173 deploy: exclude live-authoritative runtime data (outbox/toke  ·  back to Marketing Command Center  ·  Vendor IG Reporting: wire never-expiring page token, page-to 959a2f0 →