← 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
M .gitignoreM public/crm-mindmap.htmlM public/crm.htmlM server.js
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 →