← 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
M .gitignoreM public/panels/clients.jsM server.js
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 + '