← back to Marketing Command Center
public/panels/clients.js
360 lines
/* global window, document, location */
// Clients & Prospects panel — browse FileMaker clients + Google-Places prospect
// groups, each lazy-loaded, with LinkedIn + Instagram discovery links, emails,
// and per-contact "contacted" tracking (localStorage). Reads a manifest at
// /data/clients-manifest.json listing the staged datasets. Nothing here uploads
// to Constant Contact.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['clients'] = {
async init(root) {
const ORIGIN = location.origin;
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const jget = async u => { const r = await fetch(ORIGIN + u, { credentials: 'same-origin' }); if (!r.ok) throw new Error(u + ' → ' + r.status); return r.json(); };
const CAP = 300;
const CK = id => 'mcc_cl_' + id; // localStorage key per group
const done = id => { try { return new Set(JSON.parse(localStorage.getItem(CK(id)) || '[]')); } catch { return new Set(); } };
const save = (id, set) => { try { localStorage.setItem(CK(id), JSON.stringify([...set])); } catch {} };
// Per-channel action log: when you Follow/message a contact on LinkedIn/IG, stamp the date+time.
const AK = id => 'mcc_cl_acted_' + id;
const acts = id => { try { return JSON.parse(localStorage.getItem(AK(id)) || '{}'); } catch { return {}; } };
const saveActs = (id, a) => { try { localStorage.setItem(AK(id), JSON.stringify(a)); } catch {} };
// Per-contact free-text notes: keyed by group id → { recId: noteText }. Local-only.
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.
function norm(rec, kind) {
if (kind === 'prospects') {
// Full formatted address; fall back to composing one from the parts.
const address = rec.address || [rec.street, [rec.city, rec.state].filter(Boolean).join(', '), rec.postal].filter(Boolean).join(', ');
return { id: (rec.website || rec.title || '').toLowerCase(), label: rec.title || '(business)', sub: [rec.city, rec._group].filter(Boolean).join(' · '),
email: rec.email || '', website: rec.website || '', phone: rec.phone || '', phoneRaw: rec.phoneUnformatted || rec.phone || '',
address, mapsUrl: rec.mapsUrl || '', category: rec.category || '', q: [rec.title, rec.city].filter(Boolean).join(' '),
_raw: rec, _all: Object.values(rec).filter(v => v && typeof v === 'string').join(' ').toLowerCase() };
}
const label = rec.name || rec.company || '(client)';
const sub = rec.name ? [rec.company, [rec.city, rec.state].filter(Boolean).join(', ')].filter(Boolean).join(' · ')
: [rec.city, rec.state].filter(Boolean).join(', ');
return { id: rec.account || (rec.company + '|' + rec.name).toLowerCase(), label, sub, email: rec.email || '', website: '', phone: rec.phone || '', phoneRaw: rec.phone || '',
address: '', mapsUrl: '', category: '',
q: [rec.name, rec.company, rec.city].filter(Boolean).join(' '),
_raw: rec, _all: Object.values(rec).filter(v => v && typeof v === 'string').join(' ').toLowerCase() };
}
const liURL = q => 'https://www.linkedin.com/search/results/all/?keywords=' + encodeURIComponent(q);
const igURL = q => 'https://www.google.com/search?q=' + encodeURIComponent(q + ' instagram');
// Human-readable domain for the website chip — turns a raw URL into e.g. "bsbdesign.com".
const hostLabel = u => { try { return new URL(/^https?:\/\//.test(u) ? u : 'http://' + u).hostname.replace(/^www\./, ''); } catch { return 'website'; } };
const telHref = p => 'tel:' + String(p || '').replace(/[^\d+]/g, '');
// Recommend the single best way to CONTACT and to FOLLOW each firm, from the
// data we actually hold. Contact prefers a direct inbox; follow prefers a
// confirmed social profile over a search. Returns {contact, follow, keys}.
function bestChannels(r) {
let contact, ckey;
if (r.email) { contact = '✉️ Email — ' + r.email; ckey = 'mail'; }
else if (r.phone) { contact = '📞 Call — ' + r.phone; ckey = 'phone'; }
else if (r.website) { contact = '🌐 Website form — ' + hostLabel(r.website); ckey = 'site'; }
else { contact = '🔎 Google the firm'; ckey = ''; }
let follow, fkey;
if (r._raw.instagram) { follow = 'Instagram'; fkey = 'ig'; }
else if (r._raw.linkedin) { follow = 'LinkedIn'; fkey = 'in'; }
else { follow = 'search on LinkedIn / IG'; fkey = ''; }
return { contact, follow, ckey, fkey };
}
let MANIFEST = [], current = null, RECS = [];
try {
const m = await jget('/data/clients-manifest.json');
MANIFEST = m.datasets || [];
} catch { $('#cl-banner').textContent = 'No client datasets staged yet (data/clients-manifest.json missing).'; return; }
if (!MANIFEST.length) { $('#cl-banner').textContent = 'No client datasets staged yet.'; return; }
// Synthetic "All Prospects" group = every prospect city merged into one list.
const prospectDs = MANIFEST.filter(x => x.kind === 'prospects');
if (prospectDs.length > 1) {
const fmIdx = MANIFEST.findIndex(x => x.id === 'fm-clients');
MANIFEST.splice(fmIdx >= 0 ? fmIdx + 1 : 0, 0, {
id: 'all-prospects', label: '★ All Prospects', kind: 'prospects',
files: prospectDs.map(x => x.file), labels: prospectDs.map(x => x.label),
count: prospectDs.reduce((n, x) => n + (x.count || 0), 0),
});
}
$('#cl-banner').hidden = true;
$('#cl-groupcount').textContent = MANIFEST.length + ' groups';
$('#cl-tabs').innerHTML = MANIFEST.map(d =>
`<button class="btn ghost" data-id="${esc(d.id)}">${esc(d.label)}${d.count ? ` <span class="pill" style="font-size:9px;">${d.count.toLocaleString()}</span>` : ''}</button>`).join('');
$('#cl-tabs').querySelectorAll('button').forEach(b => b.onclick = () => selectGroup(b.dataset.id));
// Coverage summary across the real groups (exclude the synthetic All-Prospects).
const real = MANIFEST.filter(x => !x.files);
const sum = k => real.reduce((n, x) => n + (x[k] || 0), 0);
const totContacts = real.reduce((n, x) => n + (x.count || 0), 0);
$('#cl-coverage').innerHTML = `Across <b>${real.length}</b> groups · <b>${totContacts.toLocaleString()}</b> contacts · ` +
`<b>${sum('with_email').toLocaleString()}</b> emails · <b>${sum('with_instagram').toLocaleString()}</b> Instagram · <b>${sum('with_linkedin').toLocaleString()}</b> LinkedIn`;
async function selectGroup(id) {
const d = MANIFEST.find(x => x.id === id); if (!d) return;
current = d;
$('#cl-tabs').querySelectorAll('button').forEach(b => b.className = 'btn ' + (b.dataset.id === id ? 'gold' : 'ghost'));
$('#cl-controls').hidden = false;
$('#cl-grouptitle').textContent = d.label;
$('#cl-list').innerHTML = '<div class="loading">Loading…</div>';
let data;
if (d.files) { // merged "All Prospects" — load every group, dedup, merge contact fields
const parts = await Promise.all(d.files.map(f => jget('/data/' + f).catch(() => null)));
const hostOf = u => { try { return new URL(u.startsWith('http') ? u : 'http://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } };
const seen = new Map();
parts.forEach((p, i) => { if (!p) return; (p.businesses || []).forEach(b => {
const key = hostOf(b.website) || ((b.title || '').toLowerCase() + '|' + (b.city || '').toLowerCase());
if (!seen.has(key)) { seen.set(key, Object.assign({ _group: d.labels[i] }, b)); }
else { const ex = seen.get(key); // fill gaps from the duplicate
ex.email = ex.email || b.email; ex.instagram = ex.instagram || b.instagram;
ex.linkedin = ex.linkedin || b.linkedin; ex.phone = ex.phone || b.phone; }
}); });
const businesses = [...seen.values()];
data = { businesses, source: `all prospect groups merged (deduped from ${parts.reduce((n, p) => n + (p ? (p.businesses || []).length : 0), 0).toLocaleString()})`, with_email: businesses.filter(b => b.email).length };
} else {
try { data = await jget('/data/' + d.file); } catch { $('#cl-list').innerHTML = '<div class="card muted">Could not load ' + esc(d.file) + '.</div>'; return; }
}
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' : '';
$('#cl-licount').textContent = nLi ? nLi.toLocaleString() + ' LinkedIn' : '';
$('#cl-groupnote').textContent = `Source: ${data.source || d.file}${data.updated ? ' · updated ' + data.updated : ''}. Each card shows the firm's full address (📍 opens Google Maps), phone (📞 tap to call), website domain, and a ⭐ Best channel to contact + follow. LinkedIn opens a people/company search; IG opens a Google “<name> instagram” lookup — open the right match and follow.`;
$('#cl-search').value = '';
$('#cl-search').oninput = render;
$('#cl-sort').value = sortMode();
$('#cl-sort').onchange = () => { try { localStorage.setItem(SK, $('#cl-sort').value); } catch {} render(); };
FILTERS.clear();
$('#cl-filters').querySelectorAll('[data-filter]').forEach(b => {
b.className = 'btn ghost';
b.onclick = () => { const f = b.dataset.filter; if (FILTERS.has(f)) { FILTERS.delete(f); b.className = 'btn ghost'; } else { FILTERS.add(f); b.className = 'btn gold'; } render(); };
});
$('#cl-copymsg').textContent = '';
$('#cl-copyemails').onclick = copyEmails;
$('#cl-copyunacted').onclick = copyUnactioned;
$('#cl-density').onclick = toggleDensity;
applyDensity();
$('#cl-reset').onclick = () => { if (confirm('Clear contacted checks for ' + d.label + '?')) { save(d.id, new Set()); render(); } };
$('#cl-export').onclick = () => exportCSV(d);
render();
}
const SK = 'mcc_cl_sort';
const DK = 'mcc_cl_density';
const FILTERS = new Set(); // active quick-filters: email | instagram | linkedin | uncontacted
const sortMode = () => { try { return localStorage.getItem(SK) || 'default'; } catch { return 'default'; } };
function sortRecs(list) {
const az = (a, b) => a.label.localeCompare(b.label);
const has = f => (a, b) => ((b._raw[f] ? 1 : 0) - (a._raw[f] ? 1 : 0)) || az(a, b);
const cmp = {
name: az,
city: (a, b) => (a._raw.city || '').localeCompare(b._raw.city || '') || az(a, b),
state: (a, b) => (a._raw.state || '').localeCompare(b._raw.state || '') || az(a, b),
email: (a, b) => ((b.email ? 1 : 0) - (a.email ? 1 : 0)) || az(a, b),
instagram: has('instagram'),
linkedin: has('linkedin'),
}[sortMode()];
return cmp ? list.slice().sort(cmp) : list;
}
function currentMatches() {
const q = ($('#cl-search').value || '').trim().toLowerCase();
let list = q ? RECS.filter(r => r._all.includes(q)) : RECS;
if (FILTERS.size) {
const set = FILTERS.has('uncontacted') ? done(current.id) : null;
const A = FILTERS.has('unactioned') ? acts(current.id) : null;
list = list.filter(r =>
(!FILTERS.has('email') || r.email) &&
(!FILTERS.has('instagram') || r._raw.instagram) &&
(!FILTERS.has('linkedin') || r._raw.linkedin) &&
(!FILTERS.has('uncontacted') || !set.has(r.id)) &&
(!FILTERS.has('unactioned') || !(A[r.id] && (A[r.id].in || A[r.id].ig))));
}
return sortRecs(list);
}
async function copyList(emails, label) {
if (!emails.length) { $('#cl-copymsg').textContent = 'no emails in view'; return; }
try { await navigator.clipboard.writeText(emails.join(', ')); $('#cl-copymsg').textContent = `copied ${emails.length.toLocaleString()} ${label}`; }
catch { $('#cl-copymsg').textContent = 'copy blocked — export CSV instead'; }
}
function copyEmails() { copyList(Array.from(new Set(currentMatches().map(r => r.email).filter(Boolean))), 'emails'); }
function copyUnactioned() {
const A = acts(current.id);
const emails = Array.from(new Set(currentMatches()
.filter(r => { const a = A[r.id]; return !(a && (a.in || a.ig)); })
.map(r => r.email).filter(Boolean)));
copyList(emails, 'not-actioned emails');
}
function applyDensity() {
const compact = (() => { try { return localStorage.getItem(DK) === 'compact'; } catch { return false; } })();
$('#cl-list').classList.toggle('cl-compact', compact);
$('#cl-density').textContent = compact ? 'Comfortable' : 'Compact';
}
function toggleDensity() {
const compact = !$('#cl-list').classList.contains('cl-compact');
try { localStorage.setItem(DK, compact ? 'compact' : 'comfortable'); } catch {}
applyDensity();
}
// Review-oriented CSV export of the current group (respects the filter).
// Includes emails + the LinkedIn/Instagram discovery URLs. Local download —
// this does NOT send anything to Constant Contact.
function exportCSV(d) {
const rows = currentMatches();
const A = acts(d.id);
const N = notesOf(d.id);
const noteOf = r => N[r.id] || '';
const actedIn = r => { const a = A[r.id]; return a && a.in ? fmtWhen(a.in) : ''; };
const actedIg = r => { const a = A[r.id]; return a && a.ig ? fmtWhen(a.ig) : ''; };
const cell = v => { v = String(v == null ? '' : v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
let headers, line;
if (d.kind === 'prospects') {
headers = ['Business', 'Category', 'Address', 'City', 'State', 'Phone', 'Website', 'Email', 'Best contact', 'LinkedIn', 'Instagram', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
line = r => [r._raw.title, r.category || '', r.address || '', r._raw.city, r._raw.state || '', r._raw.phone, r._raw.website, r._raw.email, bestChannels(r).contact, r._raw.linkedin || liURL(r.q), r._raw.instagram || igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
} else {
headers = ['Account', 'Company', 'Name', 'City', 'State', 'Email', 'Phone', 'LinkedIn Search', 'Instagram Search', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
line = r => [r._raw.account, r._raw.company, r._raw.name, r._raw.city, r._raw.state, r._raw.email, r._raw.phone, liURL(r.q), igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
}
const csv = [headers.join(','), ...rows.map(r => line(r).map(cell).join(','))].join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = (d.file || ('prospects-' + d.id + '.json')).replace(/\.json$/, '') + '.csv';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
// ── Right-side drawer: open a chip target in-panel instead of a new tab ──
// Firm websites embed fine; LinkedIn/Instagram/Google refuse framing
// (X-Frame-Options / CSP frame-ancestors), so those show a one-click card.
const NO_FRAME = /(^|\.)(linkedin\.com|instagram\.com|google\.[a-z.]+|facebook\.com|twitter\.com|x\.com|threads\.net)$/i;
const embeddable = url => { try { return !NO_FRAME.test(new URL(url).hostname.replace(/^www\./, '')); } catch { return false; } };
function openDrawer(url, title, kind) {
const dr = $('#cl-drawer'); if (!dr) { window.open(url, '_blank', 'noopener'); return; }
$('#cl-dr-title').textContent = title || '';
$('#cl-dr-kind').textContent = kind || '';
$('#cl-dr-open').href = url; $('#cl-dr-fb-open').href = url;
const frame = $('#cl-dr-frame'), fb = $('#cl-dr-fallback');
if (embeddable(url)) {
fb.hidden = true; frame.hidden = false; frame.src = url;
} else {
frame.hidden = true; frame.src = 'about:blank'; fb.hidden = false;
$('#cl-dr-fb-msg').textContent = `${kind || 'This site'} blocks being shown inside another page, so it can’t load here. Open it directly:`;
$('#cl-dr-fb-url').textContent = url;
}
dr.classList.add('open'); $('#cl-scrim').classList.add('open'); dr.setAttribute('aria-hidden', 'false');
}
function closeDrawer() {
const dr = $('#cl-drawer'); if (!dr) return;
dr.classList.remove('open'); $('#cl-scrim').classList.remove('open'); dr.setAttribute('aria-hidden', 'true');
const f = $('#cl-dr-frame'); if (f) f.src = 'about:blank';
}
if ($('#cl-dr-close')) $('#cl-dr-close').onclick = closeDrawer;
if ($('#cl-scrim')) $('#cl-scrim').onclick = closeDrawer;
if (!window.__clDrawerEsc) { window.__clDrawerEsc = true; document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); }); }
function render() {
const d = current; const set = done(d.id);
const matches = currentMatches();
const shown = matches.slice(0, CAP);
const more = matches.length - shown.length;
const A = acts(d.id);
const N = notesOf(d.id);
$('#cl-list').innerHTML = `<div class="card"><div>${shown.map((r, i) => {
const on = set.has(r.id);
const note = N[r.id] || '';
const liDir = !!r._raw.linkedin, igDir = !!r._raw.instagram;
const a = A[r.id] || {};
const sub = r.sub ? `<span class="muted" style="font-size:11px;">${esc(r.sub)}</span>` : '';
const best = bestChannels(r);
const bestOn = k => (k && (best.ckey === k || best.fkey === k)) ? ' best' : ''; // gold-ring the recommended channel
const addr = r.address
? `<span class="cl-addr">📍 ${r.mapsUrl
? `<a href="${esc(r.mapsUrl)}" target="_blank" rel="noopener" title="Open in Google Maps" onclick="event.stopPropagation()">${esc(r.address)}</a> ↗`
: esc(r.address)}${r.category ? ` · ${esc(r.category)}` : ''}</span>`
: '';
const stamp = (ch) => `<span class="cl-acted" id="acted-${ch}-${i}">${a[ch] ? '🕓 ' + esc(fmtWhen(a[ch])) : ''}</span>`;
const chips =
(r.email ? `<a class="cl-chip mail${bestOn('mail')}" href="mailto:${esc(r.email)}" title="Email — ${esc(r.email)}" onclick="event.stopPropagation()">✉︎ ${esc(r.email)}</a>` : '') +
(r.phone ? `<a class="cl-chip phone${bestOn('phone')}" href="${esc(telHref(r.phoneRaw))}" title="Call ${esc(r.phone)}" onclick="event.stopPropagation()">📞 ${esc(r.phone)}</a>` : '') +
(r.website ? `<a class="cl-chip site${bestOn('site')}" href="${esc(r.website)}" target="_blank" rel="noopener" title="${esc(r.website)}" onclick="event.stopPropagation()">🌐 ${esc(hostLabel(r.website))} ↗</a>` : '') +
`<a class="cl-chip in ${liDir ? 'direct' : 'search'}${bestOn('in')}" data-ch="in" data-idx="${i}" href="${esc(r._raw.linkedin || liURL(r.q))}" target="_blank" rel="noopener" title="${liDir ? 'LinkedIn profile' : 'LinkedIn search'}">in · ${liDir ? 'Follow' : 'Find'} ↗</a>` + stamp('in') +
`<a class="cl-chip ig ${igDir ? 'direct' : 'search'}${bestOn('ig')}" data-ch="ig" data-idx="${i}" href="${esc(r._raw.instagram || igURL(r.q))}" target="_blank" rel="noopener" title="${igDir ? 'Instagram profile' : 'Instagram search'}">IG · ${igDir ? 'Follow' : 'Find'} ↗</a>` + stamp('ig');
const bestLine = `<span class="cl-best">⭐ <b>Best:</b> ${esc(best.contact)} · <b>Follow</b> on ${esc(best.follow)}</span>`;
return `<label class="cl-row" style="display:flex;align-items:flex-start;gap:9px;padding:8px 0;border-bottom:1px solid var(--line,#eee);${on ? 'opacity:.5;' : ''}">
<input type="checkbox" data-id="${esc(r.id)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;margin-top:2px;">
<span style="flex:1;display:flex;flex-direction:column;line-height:1.3;min-width:0;">
<b style="font-size:13px;${on ? 'text-decoration:line-through;' : ''}">${esc(r.label)}</b>
${sub}
${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" 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>`;
// Chip clicks open the target in the RIGHT DRAWER (not a new tab); in/ig also stamp the date+time.
$('#cl-list').querySelectorAll('a.cl-chip.site, a.cl-chip.in, a.cl-chip.ig').forEach(el => el.addEventListener('click', ev => {
ev.preventDefault(); ev.stopPropagation();
const url = el.getAttribute('href');
const isIn = el.classList.contains('in'), isIg = el.classList.contains('ig');
if (isIn || isIg) {
const idx = +el.dataset.idx, ch = el.dataset.ch, rec = shown[idx];
const A2 = acts(d.id); (A2[rec.id] = A2[rec.id] || {})[ch] = Date.now(); saveActs(d.id, A2);
const span = document.getElementById(`acted-${ch}-${idx}`); if (span) span.textContent = '🕓 ' + fmtWhen(A2[rec.id][ch]);
}
const row = el.closest('.cl-row'); const name = row ? (row.querySelector('b') ? row.querySelector('b').textContent : '') : '';
openDrawer(url, name, isIn ? 'LinkedIn' : isIg ? 'Instagram' : 'Website');
}));
$('#cl-progress').textContent = `${set.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
$('#cl-list').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
const s = done(d.id); cb.checked ? s.add(cb.dataset.id) : s.delete(cb.dataset.id); save(d.id, s);
const row = cb.closest('.cl-row'); if (row) { row.style.opacity = cb.checked ? '.5' : ''; const b = row.querySelector('b'); if (b) b.style.textDecoration = cb.checked ? 'line-through' : ''; }
$('#cl-progress').textContent = `${s.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
});
// Per-contact notes: auto-grow + auto-save to localStorage on each keystroke.
$('#cl-list').querySelectorAll('textarea.cl-note').forEach(ta => {
const grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 220) + 'px'; };
if (ta.value.trim()) grow();
ta.addEventListener('focus', grow);
ta.addEventListener('input', () => {
grow();
const rec = shown[+ta.dataset.idx]; if (!rec) return;
const O = notesOf(d.id);
const v = ta.value.trim();
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 + '