[object Object]

← back to Rentv

crm: highlight in green everyone already followed/connected on LinkedIn across CRM list+table+mind map

857d4ae21f64ec74736951f87a3c19505c03e757 · 2026-08-12 17:02:23 -0700 · Steve Abrams

- server: match every contact name against data/linkedin-connections.csv (LinkedIn export) or data/linkedin-follows.json -> li_follow flag on /api/crm
- crm.html: green row border+tint + '🟢 in Following' badge (list, grid, table); '🟢 N already following' in the count line
- crm-mindmap.html: followed contacts rendered bright green + legend entry
- connections file gitignored (PII); feature dormant until the export is dropped in

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

Files touched

Diff

commit 857d4ae21f64ec74736951f87a3c19505c03e757
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 17:02:23 2026 -0700

    crm: highlight in green everyone already followed/connected on LinkedIn across CRM list+table+mind map
    
    - server: match every contact name against data/linkedin-connections.csv (LinkedIn export) or data/linkedin-follows.json -> li_follow flag on /api/crm
    - crm.html: green row border+tint + '🟢 in Following' badge (list, grid, table); '🟢 N already following' in the count line
    - crm-mindmap.html: followed contacts rendered bright green + legend entry
    - connections file gitignored (PII); feature dormant until the export is dropped in
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore              |  2 ++
 public/crm-mindmap.html |  3 ++-
 public/crm.html         | 17 ++++++++++++++---
 server.js               | 32 ++++++++++++++++++++++++++++++++
 4 files changed, 50 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index a71617cd..21527f54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,5 @@ public/audio/
 data/labj2026-photos/_review/
 data/*.bak-*
 data/crm-imported.jsonl.bak-*
+data/linkedin-connections.csv
+data/linkedin-follows.json
diff --git a/public/crm-mindmap.html b/public/crm-mindmap.html
index e84c7823..60dda790 100644
--- a/public/crm-mindmap.html
+++ b/public/crm-mindmap.html
@@ -53,6 +53,7 @@
     <div><i style="background:#4ea1ff"></i>RE build DB (CRCP + owners)</div>
     <div><i style="background:#37d39b"></i>LABJ list category</div>
     <div><i style="background:#e7ecf1"></i>Matched contact</div>
+    <div><i style="background:#2ecc71"></i>🟢 Already following on LinkedIn</div>
     <div><i style="background:#f2c14e"></i>RE-DB match cluster</div>
   </div>
   <div class="stat" id="stat">loading…</div>
@@ -96,7 +97,7 @@ function buildGraph(){
     if(!cats[cat]){ const a=rnd(i+1)*Math.PI*2; cats[cat]=add({id:'cat:'+cat,t:'cat',label:cat,r:11,color:'#37d39b',x:W/2+Math.cos(a)*120,y:H/2+Math.sin(a)*120,n:0}); EDGES.push(['__hub','cat:'+cat,.02]); }
     cats[cat].n++;
     const cid='c:'+c.id, a=rnd(i+7)*Math.PI*2;
-    add({id:cid,t:'contact',ref:c,label:c.name||c.firm||'(contact)',r:Math.min(10,4+Math.sqrt(c.xref.count)),color:'#e7ecf1',x:W/2+Math.cos(a)*220,y:H/2+Math.sin(a)*220});
+    add({id:cid,t:'contact',ref:c,li:!!c.li_follow,label:c.name||c.firm||'(contact)',r:Math.min(11,4+Math.sqrt(c.xref.count))+(c.li_follow?2:0),color:c.li_follow?'#2ecc71':'#e7ecf1',x:W/2+Math.cos(a)*220,y:H/2+Math.sin(a)*220});
     EDGES.push(['cat:'+cat,cid,.03]);
     const mid='m:'+c.id, a2=rnd(i+13)*Math.PI*2;
     add({id:mid,t:'match',ref:c,label:'⇄ '+c.xref.count,count:c.xref.count,r:Math.min(16,5+Math.sqrt(c.xref.count)*1.4),color:'#f2c14e',x:W/2+180+Math.cos(a2)*160,y:H/2+Math.sin(a2)*160});
diff --git a/public/crm.html b/public/crm.html
index 73c84def..facd6b63 100644
--- a/public/crm.html
+++ b/public/crm.html
@@ -76,6 +76,13 @@
   .seg-chip{background:#0e141a;border:1px solid #2a333d;border-radius:8px;padding:4px 9px;font-size:11.5px;font-weight:700;color:#c3cbd3}
   .empty{padding:50px 20px;text-align:center;color:#6b747d}
   .hint{padding:9px 14px;font-size:12px;color:#8b939b;background:#10161d;border-bottom:1px solid var(--line)}
+  /* ── LinkedIn "already following/connected" green highlight across all RE-build surfaces ── */
+  .rows .row.lifollow{box-shadow:inset 4px 0 0 #2ecc71;background:rgba(46,204,113,.06)}
+  .rows .row.lifollow .nm{color:#eafff2}
+  .li-badge{display:inline-flex;align-items:center;gap:3px;background:rgba(46,204,113,.16);border:1px solid #2ecc71;color:#8affc0;border-radius:20px;padding:1px 8px;font-size:10px;font-weight:800;margin-left:6px;white-space:nowrap}
+  .grid .row.lifollow{border-color:#2ecc71;box-shadow:0 0 0 1px #2ecc71 inset}
+  .tbl tbody tr.lifollow td{background:rgba(46,204,113,.11)}
+  .tbl tbody tr.lifollow td:first-child{box-shadow:inset 4px 0 0 #2ecc71}
   /* ── table mode: every scanned field a sortable column, full-width across the screen ── */
   .content.tablemode .listwrap{flex:1;min-height:0;overflow:hidden;display:flex}
   .content.tablemode #rows{flex:1;min-height:0}
@@ -208,6 +215,8 @@ const FIELDS=[['type','Type'],['firm','Firm'],['email','Email'],['phone','Phone'
 let S={
   type: LS('type','all'), q:'', sort: LS('sort','newest'), mode: LS('mode','list'),
   dens: +LS('dens','3'), sel:null, sortDir: LS('sortDir','asc'),
+  tcolW: (()=>{try{return JSON.parse(LS('tcolW',''))||{};}catch{return {};}})(),
+  tcolHidden: (()=>{try{return JSON.parse(LS('tcolHidden',''))||{};}catch{return {};}})(),
   fields: (()=>{ try{return JSON.parse(LS('fields',''))||null;}catch{return null;} })() || {type:1,firm:1,email:1,phone:1,city:1,created:1,drill:1},
 };
 let DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[]};
@@ -307,7 +316,7 @@ function tableHTML(rows){
     let v=c[k]; if(k==='rank'&&v!=null) v='#'+v;
     return `<td title="${esc(v==null?'':v)}">${esc(v==null?'':v)}</td>`;
   }).join('');
-  return `<div class="tblwrap"><table class="tbl"><thead><tr>${head}</tr></thead><tbody>${rows.map(c=>`<tr data-id="${esc(c.id)}" class="${S.sel===c.id?'sel':''}">${cell(c)}</tr>`).join('')}</tbody></table></div>`;
+  return `<div class="tblwrap"><table class="tbl"><thead><tr>${head}</tr></thead><tbody>${rows.map(c=>`<tr data-id="${esc(c.id)}" class="${S.sel===c.id?'sel':''} ${c.li_follow?'lifollow':''}">${cell(c)}</tr>`).join('')}</tbody></table></div>`;
 }
 function sortRows(rows){
   let key=S.sort, dir=S.sortDir||'asc';
@@ -350,10 +359,10 @@ function rowHTML(c){
   if(F.email&&c.email) bits.push(esc(c.email));
   if(F.phone&&c.phone) bits.push(esc(c.phone));
   const av = c.photo ? `<img class="av" src="${esc(c.photo)}" loading="lazy" alt="" onerror="this.remove()">` : (c.type==='directory'?`<span class="av ph">${esc((c.name||'?').trim()[0]||'?')}</span>`:'');
-  return `<div class="row ${S.sel===c.id?'sel':''}" data-id="${esc(c.id)}">
+  return `<div class="row ${S.sel===c.id?'sel':''} ${c.li_follow?'lifollow':''}" data-id="${esc(c.id)}">
     ${av}
     <div class="rmeta">
-      <div class="nm">${esc(c.name)}${F.type?`<span class="badge t-${esc(c.type)}">${esc(typeLabel(c.type))}</span>`:''}</div>
+      <div class="nm">${esc(c.name)}${c.li_follow?'<span class="li-badge">🟢 in Following</span>':''}${F.type?`<span class="badge t-${esc(c.type)}">${esc(typeLabel(c.type))}</span>`:''}</div>
       ${bits.length?`<div class="f">${bits.join(' · ')}</div>`:''}
       ${F.created?`<div class="when" title="${esc(c.created_at||'')}">🕓 ${esc(fmtWhen(c.created_at))}</div>`:''}
       ${F.drill?`<div class="drill">${drillFor(c)}</div>`:''}
@@ -373,6 +382,8 @@ async function render(){
   $('countline').textContent = isRegistry(S.type)
     ? `${sorted.length.toLocaleString()} match${sorted.length===1?'':'es'} · ${typeLabel(S.type)} (registry search)`
     : `${sorted.length.toLocaleString()} of ${Number(DATA.counts[S.type]??DATA.counts.all??0).toLocaleString()} · ${S.type==='all'?'all contacts':typeLabel(S.type)}`;
+  const nfollow=sorted.filter(c=>c.li_follow).length;
+  if(nfollow) $('countline').innerHTML = esc($('countline').textContent)+` · <span style="color:#8affc0;font-weight:800">🟢 ${nfollow.toLocaleString()} already following on LinkedIn</span>`;
   if(S.mode==='table'){
     $('rows').innerHTML = sorted.length ? tableHTML(sorted) : (hint?'':'<div class="empty">No contacts match.</div>');
     $('rows').querySelectorAll('.tbl thead th[data-col]').forEach(th=>th.onclick=()=>{
diff --git a/server.js b/server.js
index 928879c2..dfaa13d0 100644
--- a/server.js
+++ b/server.js
@@ -2347,6 +2347,35 @@ function crmLoadImported() {
       }));
   } catch { return []; }
 }
+// LinkedIn "already following / connected" set — matched against every contact so the UI can
+// highlight them in green across all RE-build surfaces. Drop your official LinkedIn export at
+// data/linkedin-connections.csv (Settings → Data Privacy → Get a copy of your data → Connections)
+// OR a simple data/linkedin-follows.json (array of names / {name,company}). Absent file = no-op.
+function liNorm(s){ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }
+function loadLinkedInFollows() {
+  const set = new Set();
+  try {
+    const j = JSON.parse(fs.readFileSync(path.join(DATA, 'linkedin-follows.json'), 'utf8'));
+    for (const x of (Array.isArray(j) ? j : [])) { const n = typeof x === 'string' ? x : (x && x.name); if (n) set.add(liNorm(n)); }
+  } catch { /* no json */ }
+  try {
+    const lines = fs.readFileSync(path.join(DATA, 'linkedin-connections.csv'), 'utf8').split(/\r?\n/);
+    let hi = lines.findIndex(l => /(^|,)\s*"?First Name"?\s*,/i.test(l));           // LinkedIn export has a Notes preamble
+    if (hi < 0) hi = lines.findIndex(l => /first name/i.test(l));
+    if (hi >= 0) {
+      const cols = lines[hi].split(',').map(c => c.replace(/"/g, '').trim().toLowerCase());
+      const fi = cols.indexOf('first name'), li2 = cols.indexOf('last name'), ni = cols.indexOf('name');
+      for (let i = hi + 1; i < lines.length; i++) {
+        if (!lines[i].trim()) continue;
+        const f = lines[i].match(/("([^"]|"")*"|[^,]*)(,|$)/g) || [];
+        const cell = k => (f[k] || '').replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"').trim();
+        const nm = ni >= 0 ? cell(ni) : [cell(fi), cell(li2)].filter(Boolean).join(' ');
+        if (nm) set.add(liNorm(nm));
+      }
+    }
+  } catch { /* no csv */ }
+  return set;
+}
 app.get('/api/crm', adminOnly, async (req, res) => {
   try {
     let subs = [];
@@ -2362,8 +2391,11 @@ app.get('/api/crm', adminOnly, async (req, res) => {
     const owners = crmNormalizeOwners(readJSON('la-commercial.json', { marquee: [] }));
     const commercial = crmNormalizeCommercial(commercialRows);
     const directory = crmLoadImported();      // manually-imported contacts (scanned lists, business cards, etc.)
+    const liFollows = loadLinkedInFollows();
     const all = [...ownedContacts, ...commercial, ...owners, ...directory].map((c) => ({
       ...c,
+      // green-highlight flag: is this person someone Steve already follows/connects with on LinkedIn?
+      li_follow: !!(c.name && liFollows.has(liNorm(c.name))),
       // one precomputed lowercase search blob per contact (no per-keystroke JSON.stringify).
       _s: [c.name, c.firm, c.email, c.phone, c.city, c.title, c.interest, c.source, c.dre_license, c.ain, c.list_category, c.address].filter(Boolean).join(' ').toLowerCase(),
     }));

← a1db61ae crm: cross-reference mind map (/crm-mindmap) — force-directe  ·  back to Rentv  ·  auto-data-snapshot: 2026-08-12T17:03:39 (8 data files) — 5x/ 1c5d23ba →