[object Object]

← back to Commercialrealestate

CRCP: Frank onload 4-unit multifamily default filter + non-warrantable condos section + per-agent private notes

cf0bd41042599455cf8102dbd5fa6a11e0c754f4 · 2026-08-19 11:11:39 -0700 · Steve

- crcp-notes.js: generalize to a scope factory; add agent/broker profile notes (/api/agent-notes, data/agent-notes.json) alongside existing condo notes, per-user + updated_at timestamp
- agent-notes.js (new): reusable private-notes panel drop-in; shows last-modified date + relative update, autosaves on blur/Cmd+Enter, sign-in-aware
- agent.html + broker.html: mount the notes panel keyed by stable profile id
- crcp-accounts.js: seed Frank (Arcstone818 company) a default 'is_default' 4-Unit Multifamily saved search; add POST /api/saved-searches/:id/default; surface company on session
- deals-flow.html: auto-apply the user's default saved search on load when no URL filter is present
- condos.html: dedicated Non-Warrantable Condos section (honest FHA/warrantability proxy) with live bucket counts + real HUD-lapsed re-cert examples + one-click filter

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

Files touched

Diff

commit cf0bd41042599455cf8102dbd5fa6a11e0c754f4
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 11:11:39 2026 -0700

    CRCP: Frank onload 4-unit multifamily default filter + non-warrantable condos section + per-agent private notes
    
    - crcp-notes.js: generalize to a scope factory; add agent/broker profile notes (/api/agent-notes, data/agent-notes.json) alongside existing condo notes, per-user + updated_at timestamp
    - agent-notes.js (new): reusable private-notes panel drop-in; shows last-modified date + relative update, autosaves on blur/Cmd+Enter, sign-in-aware
    - agent.html + broker.html: mount the notes panel keyed by stable profile id
    - crcp-accounts.js: seed Frank (Arcstone818 company) a default 'is_default' 4-Unit Multifamily saved search; add POST /api/saved-searches/:id/default; surface company on session
    - deals-flow.html: auto-apply the user's default saved search on load when no URL filter is present
    - condos.html: dedicated Non-Warrantable Condos section (honest FHA/warrantability proxy) with live bucket counts + real HUD-lapsed re-cert examples + one-click filter
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/agent-notes.js    | 163 +++++++++++++++++++++++++++++++++++++++++++++++
 public/agent.html        |   6 ++
 public/broker.html       |   6 +-
 public/condos.html       |  74 ++++++++++++++++++++-
 public/deals-flow.html   |  19 ++++--
 scripts/crcp-accounts.js |  39 ++++++++++++
 scripts/crcp-notes.js    | 137 ++++++++++++++++++++++-----------------
 7 files changed, 378 insertions(+), 66 deletions(-)

diff --git a/public/agent-notes.js b/public/agent-notes.js
new file mode 100644
index 0000000..58d0676
--- /dev/null
+++ b/public/agent-notes.js
@@ -0,0 +1,163 @@
+/* agent-notes.js — reusable private-notes panel for CRCP profile pages (agent / broker / firm).
+ *
+ * Steve 2026-08-19: "on each agent page, provide space for NOTES and record any modification date
+ * and update." One drop-in any profile page includes; it renders a per-user, timestamped notes
+ * panel keyed by the profile's stable id and surfaces the last-modified date + relative "updated".
+ *
+ * Backed by the crcp-notes agent scope (GET /api/agent-notes → {notes:{id:{note,updated_at}}},
+ * POST /api/agent-notes/:id {note}). Notes are PRIVATE to the signed-in user; when nobody is signed
+ * in the panel shows a sign-in hint and stays read-only (never throws, page still renders).
+ *
+ * Usage (call once the page knows the entity id):
+ *   <script src="/agent-notes.js" defer></script>
+ *   CRCPNotes.mount({ id: 'agent:' + agentId, title: 'Notes on ' + name, mount: '#agentNotes' });
+ *
+ * Options: { id (required), base='/api/agent-notes', title='Private Notes', mount=element|selector,
+ *            subject='' } — subject is an optional line shown under the title (e.g. the agent name).
+ * $0, local, no deps. Idempotent: re-mounting with the same id refreshes in place.
+ */
+(function () {
+  'use strict';
+  const esc = s => (s == null ? '' : String(s)).replace(/&/g, '&amp;').replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  const api = (u, o) => fetch(u, Object.assign({ headers: { 'Content-Type': 'application/json' } }, o || {})).then(r => r.json());
+
+  // Human "modification date + relative update" from an ISO stamp (Steve's admin-card rule: show
+  // both the absolute date+time AND keep the precise ISO in a title attribute).
+  function fmtWhen(iso) {
+    if (!iso) return '';
+    const d = new Date(iso); if (isNaN(d)) return '';
+    const abs = d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+    const secs = Math.max(0, (Date.now() - d.getTime()) / 1000);
+    let rel;
+    if (secs < 60) rel = 'just now';
+    else if (secs < 3600) rel = Math.floor(secs / 60) + 'm ago';
+    else if (secs < 86400) rel = Math.floor(secs / 3600) + 'h ago';
+    else if (secs < 2592000) rel = Math.floor(secs / 86400) + 'd ago';
+    else rel = abs;
+    return { abs, rel, iso };
+  }
+
+  // One-time styles (scoped to .cn-* so they don't collide with a host page).
+  function ensureStyles() {
+    if (document.getElementById('cn-styles')) return;
+    const css = `
+    .cn-wrap{border:1px solid var(--line,#2a313c);border-radius:14px;background:var(--card,#161b22);padding:14px 16px;margin:16px 0}
+    .cn-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 12px;margin-bottom:8px}
+    .cn-head h3{margin:0;font-size:14px;color:var(--ink,#e6edf3)}
+    .cn-subj{color:var(--mut,#8b949e);font-size:12px}
+    .cn-when{margin-left:auto;color:var(--mut,#8b949e);font-size:11.5px}
+    .cn-when b{color:var(--gold,#ffa600);font-weight:600}
+    .cn-ta{width:100%;min-height:96px;resize:vertical;background:var(--bg2,#0b0e13);color:var(--ink,#e6edf3);
+      border:1px solid var(--line,#2a313c);border-radius:10px;padding:10px 12px;font:inherit;font-size:13px;line-height:1.5}
+    .cn-ta:focus{outline:none;border-color:var(--blue,#58a6ff)}
+    .cn-ta[disabled]{opacity:.6;cursor:not-allowed}
+    .cn-bar{display:flex;align-items:center;gap:10px;margin-top:8px}
+    .cn-save{background:var(--acc,#3fb950);color:var(--onacc,#0e1116);border:0;border-radius:8px;
+      padding:7px 14px;font-size:12px;font-weight:700;cursor:pointer}
+    .cn-save[disabled]{opacity:.5;cursor:not-allowed}
+    .cn-status{font-size:11.5px;color:var(--mut,#8b949e)}
+    .cn-status.ok{color:var(--acc,#3fb950)}
+    .cn-status.err{color:var(--red,#f85149)}
+    .cn-hint{font-size:11.5px;color:var(--mut,#8b949e);margin-top:6px}
+    .cn-hint a{color:var(--blue,#58a6ff);cursor:pointer}`;
+    const el = document.createElement('style'); el.id = 'cn-styles'; el.textContent = css;
+    document.head.appendChild(el);
+  }
+
+  function resolveMount(mount) {
+    if (mount && mount.nodeType === 1) return mount;
+    if (typeof mount === 'string') { const el = document.querySelector(mount); if (el) return el; }
+    // Fallbacks: a page-declared #agentNotes host, else the main .wrap, else <body>.
+    return document.getElementById('agentNotes') || document.querySelector('.wrap') || document.body;
+  }
+
+  const mounted = new Map(); // id -> panel root, so re-mount refreshes instead of duplicating
+
+  function mount(opts) {
+    opts = opts || {};
+    if (!opts.id) { console.warn('[agent-notes] mount() needs an id'); return; }
+    ensureStyles();
+    const base = opts.base || '/api/agent-notes';
+    const title = opts.title || 'Private Notes';
+    const host = resolveMount(opts.mount);
+
+    let root = mounted.get(opts.id);
+    if (!root) {
+      root = document.createElement('section'); root.className = 'cn-wrap';
+      mounted.set(opts.id, root);
+    }
+    root.innerHTML =
+      `<div class="cn-head"><h3>📝 ${esc(title)}</h3>` +
+        (opts.subject ? `<span class="cn-subj">${esc(opts.subject)}</span>` : '') +
+        `<span class="cn-when" id="cn-when"></span></div>` +
+      `<textarea class="cn-ta" id="cn-ta" placeholder="Private notes on this profile — call attempts, financing angle, follow-ups… (visible only to you)"></textarea>` +
+      `<div class="cn-bar"><button class="cn-save" id="cn-save">Save note</button>` +
+        `<span class="cn-status" id="cn-status"></span></div>` +
+      `<div class="cn-hint" id="cn-hint" hidden></div>`;
+    if (!root.isConnected) host.appendChild(root);
+
+    const ta = root.querySelector('#cn-ta');
+    const saveBtn = root.querySelector('#cn-save');
+    const statusEl = root.querySelector('#cn-status');
+    const whenEl = root.querySelector('#cn-when');
+    const hintEl = root.querySelector('#cn-hint');
+    let signedIn = false, lastSavedValue = '', dirty = false;
+
+    function setWhen(iso) {
+      const w = fmtWhen(iso);
+      whenEl.innerHTML = w ? `last updated <b>${esc(w.rel)}</b>` : '';
+      if (w) whenEl.title = 'Modified ' + w.abs + '  ·  ' + w.iso;
+    }
+    function status(msg, cls) { statusEl.textContent = msg || ''; statusEl.className = 'cn-status' + (cls ? ' ' + cls : ''); }
+
+    function lockSignedOut() {
+      signedIn = false; ta.disabled = true; saveBtn.disabled = true;
+      hintEl.hidden = false;
+      hintEl.innerHTML = 'Sign in to keep private notes on this profile. ' +
+        (window.CRCPNotes && window.CRCPNotes.onSignIn ? '<a id="cn-signin">Sign in</a>' : '');
+      const link = hintEl.querySelector('#cn-signin');
+      if (link) link.onclick = () => { try { window.CRCPNotes.onSignIn(); } catch (_) {} };
+    }
+
+    // Load the current note for this id.
+    api(base).then(r => {
+      signedIn = !!r.signed_in;
+      if (!signedIn) { lockSignedOut(); return; }
+      hintEl.hidden = true; ta.disabled = false; saveBtn.disabled = false;
+      const rec = (r.notes || {})[opts.id];
+      ta.value = lastSavedValue = (rec && rec.note) || '';
+      setWhen(rec && rec.updated_at);
+    }).catch(() => { status('Could not load notes', 'err'); });
+
+    function save() {
+      if (!signedIn) return;
+      const note = ta.value;
+      if (note === lastSavedValue) { status('No changes', ''); return; }
+      saveBtn.disabled = true; status('Saving…', '');
+      api(base + '/' + encodeURIComponent(opts.id), { method: 'POST', body: JSON.stringify({ note }) })
+        .then(r => {
+          if (r && r.ok) {
+            lastSavedValue = note; dirty = false;
+            const iso = (r.note && r.note.updated_at) || (note.trim() ? new Date().toISOString() : '');
+            setWhen(iso);
+            status(note.trim() ? 'Saved ✓' : 'Cleared', 'ok');
+          } else if (r && r.signed_in === false) {
+            lockSignedOut();
+          } else { status('Save failed', 'err'); }
+        })
+        .catch(() => status('Save failed', 'err'))
+        .finally(() => { saveBtn.disabled = !signedIn; });
+    }
+
+    saveBtn.addEventListener('click', save);
+    ta.addEventListener('input', () => { dirty = true; status('', ''); });
+    ta.addEventListener('blur', () => { if (dirty) save(); });          // autosave on blur
+    // ⌘/Ctrl+Enter saves without leaving the textarea.
+    ta.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
+
+    return root;
+  }
+
+  window.CRCPNotes = { mount, fmtWhen };
+})();
diff --git a/public/agent.html b/public/agent.html
index 09ba9da..d31ef0b 100644
--- a/public/agent.html
+++ b/public/agent.html
@@ -62,6 +62,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
     <div class="toolbar"><h3>Firm listings <span class="count" id="firmcount"></span> <span style="font-weight:400;font-size:11px;color:var(--mut)">— from <span id="firmname"></span>'s own site (firm inventory, not this agent's individual book)</span></h3></div>
     <div id="firmgrid" class="grid"></div>
   </div>
+  <div id="agentNotes"></div>
   <div class="note" id="prov"></div>
 </div>
 <script>
@@ -139,8 +140,13 @@ else fetch('/api/agent-profile?'+(ID?'id='+encodeURIComponent(ID):'name='+encode
     $('#firmwrap').hidden=false;
   }
   $('#prov').innerHTML=`Profile built from Designer/CRCP's own broker graph — agent resolved by name; current + past (closed/sold) listings from our records; <b>Firm listings</b> scraped directly from the firm's own website (openclaw + local model), attributed at the firm level. No aggregator (Crexi/LoopNet/etc.) data is used or shown.`;
+  // Private per-user notes on this agent (Steve 2026-08-19). Stable key: the broker DB id when we
+  // resolved one (names collide), else the name. updated_at drives the "last updated" stamp.
+  const noteKey='agent:'+(a.id!=null?a.id:('name:'+(NAME||ID||'unknown')));
+  if(window.CRCPNotes) CRCPNotes.mount({id:noteKey, title:'Private Notes', subject:a.name||NAME, mount:'#agentNotes'});
 }).catch(()=>{ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME)}</h2><div class="meta">Could not load this agent.</div></div>`; });
 </script>
+<script src="/agent-notes.js" defer></script>
 <script src="/corner-nav.js" defer></script>
 <script src="/crcp-theme.js" defer></script>
 </body></html>
diff --git a/public/broker.html b/public/broker.html
index 320f0f4..088d1f1 100644
--- a/public/broker.html
+++ b/public/broker.html
@@ -87,6 +87,7 @@
 <div class="topbar"><a class="back" href="/brokers.html">← back to broker directory</a></div>
 <div class="wrap" id="root"><div class="load">Loading broker profile…</div></div>
 
+<script src="/agent-notes.js" defer></script>
 <script src="/corner-nav.js" defer></script>
 <script src="/crcp-theme.js" defer></script>
 <script>
@@ -241,12 +242,15 @@ function render(b){
          <div class="indexed">Source: ${esc(b.source||'crexi')} · CRCP broker #${esc(b.id)}</div>
        </div>
      </div>
-   </div>`;
+   </div>
+   <div id="agentNotes"></div>`;
 
   document.title = (b.name||'Broker')+' — CRCP Profile';
   const sortSel=document.getElementById('lsort'); if(sortSel){ sortSel.value=PREF.sort; sortSel.onchange=()=>{PREF.sort=sortSel.value;localStorage.setItem('brokerLxSort',PREF.sort);renderListings();}; }
   const dens=document.getElementById('ldens'); if(dens){ dens.oninput=()=>{PREF.cols=+dens.value;localStorage.setItem('brokerLxCols',PREF.cols);renderListings();}; }
   renderListings();
+  // Private per-user notes on this broker (Steve 2026-08-19), keyed by the stable CRCP broker id.
+  if(window.CRCPNotes && b.id!=null) CRCPNotes.mount({id:'broker:'+b.id, title:'Private Notes', subject:b.name||('Broker #'+b.id), mount:'#agentNotes'});
 }
 
 function loadById(id){
diff --git a/public/condos.html b/public/condos.html
index f781f2f..29c5d3c 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -166,6 +166,32 @@
 </script>
 <div class="banner" id="banner">⚖️ Loading warrantability label…</div>
 
+<!-- Non-Warrantable Condos section (Steve 2026-08-19): a dedicated, honest FHA/warrantability
+     summary above the grid. Populated from the live classified inventory (DATA) by renderNonWarrant(). -->
+<style>
+  .nw-sec{margin:14px 22px 0;border:1px solid var(--line);border-radius:14px;background:var(--card);padding:16px 18px}
+  .nw-sec h2{margin:0 0 4px;font-size:16px;color:var(--ink);display:flex;align-items:center;gap:8px}
+  .nw-sec .lede{color:var(--mut);font-size:12.5px;line-height:1.55;margin:0 0 12px;max-width:900px}
+  .nw-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:12px}
+  .nw-tile{border:1px solid var(--line);border-radius:11px;background:var(--bg2);padding:11px 13px;cursor:pointer;transition:border-color .12s}
+  .nw-tile:hover{border-color:var(--blue)}
+  .nw-tile .n{font-size:22px;font-weight:800;line-height:1.1}
+  .nw-tile .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;margin-top:2px}
+  .nw-tile .sub{font-size:11px;color:var(--mut);margin-top:4px;line-height:1.4}
+  .nw-tile.approved .n{color:var(--acc)} .nw-tile.expired .n{color:var(--gold)}
+  .nw-tile.flagged .n{color:var(--red)} .nw-tile.notlisted .n{color:var(--mut)}
+  .nw-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:6px}
+  .nw-btn{background:var(--red);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:12.5px;font-weight:700;cursor:pointer}
+  .nw-btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
+  .nw-note{font-size:11px;color:var(--mut)}
+  .nw-ex{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
+  .nw-ex h3{margin:0 0 7px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.4px}
+  .nw-row{display:flex;flex-wrap:wrap;gap:4px 12px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:12.5px}
+  .nw-row .a{font-weight:600;color:var(--ink)} .nw-row .why{color:var(--gold);font-size:11.5px}
+  .nw-row .why.nl{color:var(--mut)}
+</style>
+<section id="nwSection" class="nw-sec" hidden></section>
+
 <div class="shell">
 <aside class="rail">
   <div class="rsec" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</h4>
@@ -575,6 +601,52 @@ function apply(){
 }
 
 // ---- boot ----
+// ── Non-Warrantable Condos section ─────────────────────────────────────────────
+// Steve 2026-08-19: "create a unwarrantable condos section with real fha and warrant info."
+// HONEST labeling (the classifier's hard rule): "non-warrantable" here = NOT on the current
+// HUD FHA-approved list — a financing-eligibility PROXY, not a lender-verified Fannie/Freddie
+// warrantability call. We split it into its real buckets so a loan officer sees WHY each is risk.
+const nwMoney=n=>(n==null||isNaN(+n))?'—':'$'+Math.round(+n).toLocaleString();
+function setWarr(v){ F.warr=v; $$('#fWarr .chip').forEach(x=>x.classList.toggle('active',x.dataset.warr===v)); apply();
+  const g=$('#grid'); if(g&&g.scrollIntoView) g.scrollIntoView({behavior:'smooth',block:'start'}); }
+function nwReason(c){
+  const ws=c.warrantable_status||c.warrant_signal;
+  if(ws==='fha_expired'){ const exp=c.fha_expiration_date||''; return {cls:'',txt:'FHA approval LAPSED'+(exp?' '+esc(exp):'')+' — re-certification required'}; }
+  if(ws==='heuristic_flag'){ const sig=(c.warrant_signals&&c.warrant_signals.signals)||[]; const n=sig[0]; return {cls:'',txt:esc((n&&n.note)||'hard non-warrantable signal flagged (condotel / investor / cash-only)')}; }
+  return {cls:'nl',txt:'Not on the FHA-approved list — verify warrantability with the lender'};
+}
+function renderNonWarrant(){
+  const sec=$('#nwSection'); if(!sec) return;
+  if(!DATA.length){ sec.hidden=true; return; }
+  const cnt={fha_approved:0,fha_expired:0,heuristic_flag:0,not_listed:0};
+  DATA.forEach(c=>{ const s=c.warrantable_status||c.warrant_signal||'not_listed'; if(cnt[s]!=null)cnt[s]++; });
+  const total=DATA.length, nonWarr=total-(cnt.fha_approved||0);
+  const pct=total?Math.round(1000*nonWarr/total)/10:0;
+  // Real HUD-lapsed examples — the most actionable: a project that WAS FHA-approved and lapsed is a
+  // re-certification opportunity. Highest-price first (biggest loan). Uses real warrant_source data.
+  const expired=DATA.filter(c=>(c.warrantable_status||c.warrant_signal)==='fha_expired')
+    .sort((a,b)=>(+b.price||0)-(+a.price||0)).slice(0,5);
+  const tile=(cls,n,lbl,sub,warr)=>`<div class="nw-tile ${cls}" data-setwarr="${warr}" title="Show only these"><div class="n">${n.toLocaleString()}</div><div class="lbl">${lbl}</div><div class="sub">${sub}</div></div>`;
+  sec.hidden=false;
+  sec.innerHTML=
+    `<h2>🚫 Non-Warrantable Condos <span style="font-weight:400;font-size:12px;color:var(--mut)">— ${nonWarr.toLocaleString()} of ${total.toLocaleString()} (${pct}%) not FHA-approved</span></h2>`+
+    `<p class="lede">A <b>non-warrantable</b> condo can't be financed with a standard conforming/FHA loan, so a buyer needs a portfolio or non-QM product (bigger down, higher rate). This is an <b>FHA/VA-approval-based proxy</b>, <b>not</b> a lender-verified Fannie/Freddie warrantability call — always confirm with the lender. Below, the ${nonWarr.toLocaleString()} non-FHA-approved condos split into their real risk buckets:</p>`+
+    `<div class="nw-tiles">`+
+      tile('approved',cnt.fha_approved,'FHA-Approved','Financeable (proxy) — on the HUD list','fha_approved')+
+      tile('expired',cnt.fha_expired,'FHA-Expired','Was approved, cert lapsed — re-cert needed','fha_expired')+
+      tile('notlisted',cnt.not_listed,'Not-Listed','Not on the FHA list — verify w/ lender','not_listed')+
+      tile('flagged',cnt.heuristic_flag,'Flagged','Hard non-warrantable signal fired','heuristic_flag')+
+    `</div>`+
+    `<div class="nw-cta"><button class="nw-btn" id="nwShowAll">Show all ${nonWarr.toLocaleString()} non-warrantable →</button>`+
+      `<button class="nw-btn ghost" id="nwShowApproved">Show FHA-approved only</button>`+
+      `<span class="nw-note">Click any tile to filter the grid to that bucket.</span></div>`+
+    (expired.length?`<div class="nw-ex"><h3>⚠ FHA-approval LAPSED — re-certification opportunities (real HUD list matches)</h3>`+
+      expired.map(c=>{ const r=nwReason(c); return `<div class="nw-row"><span class="a">${esc(c.address||c.project_name||'—')}</span><span>${esc(c.city||'')}</span><span>${nwMoney(c.price)}</span><span class="why ${r.cls}">${r.txt}</span></div>`; }).join('')+
+      `</div>`:'');
+  sec.querySelectorAll('[data-setwarr]').forEach(el=>el.onclick=()=>setWarr(el.dataset.setwarr==='fha_approved'?'fha_approved':el.dataset.setwarr));
+  const sa=sec.querySelector('#nwShowAll'); if(sa) sa.onclick=()=>setWarr('unwarrantable');
+  const ap=sec.querySelector('#nwShowApproved'); if(ap) ap.onclick=()=>setWarr('fha_approved');
+}
 async function boot(){
   try{ const r=await (await fetch('/api/condo-notes')).json(); NOTES=r.notes||{}; }catch(_){}
   try{ const r=await (await fetch('/api/condos')).json(); DATA=r.condos||[]; if(r.label) bannerDefault='⚖️ '+r.label; }catch(_){}
@@ -601,7 +673,7 @@ async function boot(){
   if(sortParam && ['price_desc','price_asc','pps_desc','year_desc','hoa_asc','proj_az','city_az','expiry_soon'].includes(sortParam)){
     SORTKEY=sortParam; const sel=$('#csort'); if(sel) sel.value=SORTKEY; try{localStorage.setItem('condoSort',SORTKEY);}catch(_){}
   }
-  buildRail(); apply();
+  buildRail(); apply(); renderNonWarrant();
 }
 
 // ---- wiring ----
diff --git a/public/deals-flow.html b/public/deals-flow.html
index deba379..6af392c 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -355,9 +355,9 @@ const ROLE_META={
   other:{label:'Member',tip:'',qa:[['📊 Deal flow','/deals-flow.html'],['🏷️ Listings','/mls.html']]}
 };
 const ROLE_OPTS=[['loan_officer','Loan Officer','mortgage / lending'],['listing_agent','Listing Agent','brokerage — listing'],['buyers_agent','Buyer’s Agent','brokerage — buy-side'],['investor','Investor','principal / capital'],['appraiser','Appraiser','valuation'],['other','Something else','']];
-function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';el.innerHTML=`<b style="color:var(--ink,#fff)">${who}</b>${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''}${ME.perm==='admin'?` · <a href="/admin.html" style="color:var(--gold)">Admin</a>`:''} · <a id="signOutLink" style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('#signOutLink').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';const co=ME.company?` · <span style="color:var(--gold)">${esc(ME.company)}</span>`:'';el.innerHTML=`<b style="color:var(--ink,#fff)">${who}</b>${co}${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''}${ME.perm==='admin'?` · <a href="/admin.html" style="color:var(--gold)">Admin</a>`:''} · <a id="signOutLink" style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('#signOutLink').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
 function renderPersona(){const p=$('#persona');if(!(ME&&ME.email&&ME.role)){p.style.display='none';return;}const m=ROLE_META[ME.role]||ROLE_META.other;const who=esc(ME.name||ME.username||'there');p.style.display='';p.innerHTML=`<h2>Welcome back, ${who}</h2><span class="role">${esc(m.label)}</span><div class="qa">${m.qa.map(([t,h])=>`<a href="${h}">${t}</a>`).join('')}</div>${m.tip?`<p class="tip">${esc(m.tip)}</p>`:''}<span class="edit" id="editRole">change role</span>`;const er=$('#editRole');if(er)er.onclick=openRole;}
-async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();renderPersona();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){loadSaved();loadWatch();if(!ME.role)openRole();}}
+async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();renderPersona();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){await loadSaved();loadWatch();maybeApplyDefault();if(!ME.role)openRole();}}
 // ── sign-in modal (username + password) ──
 function openLogin(){$('#liErr').textContent='';$('#liUser').value='';$('#liPass').value='';$('#loginModal').classList.add('on');setTimeout(()=>$('#liUser').focus(),40);}
 const signIn=openLogin;   // back-compat: save/watch/star gates call signIn() to prompt sign-in
@@ -396,11 +396,17 @@ loadTicker();
   if(lbl)lbl.addEventListener('click',()=>{if(t.classList.contains('is-dismissed'))setHidden(false);});
   try{if(sessionStorage.getItem('crcpTickerHidden')==='1')t.classList.add('is-dismissed');}catch(e){}
 })();
-async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip" data-sid="${x.id}" title="apply this search">${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
+async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip${x.is_default?' active':''}" data-sid="${x.id}" title="${x.is_default?'default — auto-applied on load · click to apply':'apply this search'}">${x.is_default?'★ ':''}${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
 async function loadWatch(){const r=await api('/api/watchlist');WATCH=new Set(r.ids||[]);render();}
 $('#saveBtn').addEventListener('click',async()=>{if(!ME||!ME.email){signIn();return;}const name=prompt('Name this saved search:','My deal filter');if(name===null)return;const filters={src:[...F.src],use:[...F.use],year:[...F.year],conf:[...F.conf],pMin:F.pMin,uMin:F.uMin,q};await api('/api/saved-searches',{method:'POST',body:JSON.stringify({name,filters})});loadSaved();});
 $('#watchBtn').addEventListener('click',()=>{if(!ME||!ME.email){signIn();return;}watchOnly=!watchOnly;$('#watchBtn').style.borderColor=watchOnly?'var(--gold)':'var(--line)';render();});
-$('#fSaved').addEventListener('click',async e=>{const del=e.target.closest('[data-del]');if(del){await fetch('/api/saved-searches/'+del.dataset.del,{method:'DELETE'});loadSaved();return;}const c=e.target.closest('[data-sid]');if(!c)return;const s=(window._saved||[]).find(x=>x.id===c.dataset.sid);if(!s)return;const f=s.filters||{};F.src=new Set(f.src||[]);F.use=new Set(f.use||[]);F.year=new Set(f.year||[]);F.conf=new Set(f.conf||[]);F.pMin=f.pMin??null;F.uMin=f.uMin??null;q=f.q||'';$('#q').value=q;render();});
+$('#fSaved').addEventListener('click',async e=>{const del=e.target.closest('[data-del]');if(del){await fetch('/api/saved-searches/'+del.dataset.del,{method:'DELETE'});loadSaved();return;}const c=e.target.closest('[data-sid]');if(!c)return;const s=(window._saved||[]).find(x=>x.id===c.dataset.sid);if(!s)return;window._defaultApplied=true;applySaved(s);});
+// Apply a saved search's filters into the live rail state (chips + min-inputs + URL). Shared by the
+// saved-chip click AND the on-load default (Steve 2026-08-19: Frank's "4-Unit Multifamily" default).
+function applySaved(s){const f=(s&&s.filters)||{};F.src=new Set(f.src||[]);F.use=new Set(f.use||[]);F.year=new Set(f.year||[]);F.conf=new Set(f.conf||[]);F.exact={};F.pMin=(f.pMin==null?null:+f.pMin);F.uMin=(f.uMin==null?null:+f.uMin);q=f.q||'';const qi=$('#q');if(qi)qi.value=q;const um=$('#uMin');if(um)um.value=(f.uMin!=null?f.uMin:'');const pm=$('#pMin');if(pm)pm.value=(f.pMin!=null?f.pMin:'');syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);writeURLFilters();render();}
+function filtersEmpty(){return !F.src.size&&!F.use.size&&!F.year.size&&!F.conf.size&&!Object.keys(F.exact).length&&F.pMin==null&&F.uMin==null&&!q;}
+// On a fresh load with no URL filters, auto-apply the user's default saved search (once).
+function maybeApplyDefault(){if(window._defaultApplied||window._hadURLFilters)return;if(!filtersEmpty())return;const def=(window._saved||[]).find(x=>x.is_default);if(!def)return;window._defaultApplied=true;applySaved(def);}
 // href-drill rule: card values drill to their filtered view (reuses the rail F filters + keeps rail chips in sync)
 function syncRailActive(attr,set){const map={s:'#fSrc',u:'#fUse',y:'#fYear',c:'#fConf'},el=$(map[attr]);if(el)el.querySelectorAll('.chip').forEach(ch=>ch.classList.toggle('active',set.has(ch.dataset[attr])));}
 // ── property-detail drill: fetch the free public-record dossier + render inline ──
@@ -507,8 +513,9 @@ Promise.all([
   DATA=c.concat(e).concat(f).concat(u);
   buildRail();
   // Apply any URL filter state (from a shared/bookmarked card drill-link) before the first render,
-  // then reflect it into the rail chips so the active filters are visible.
-  readURLFilters();
+  // then reflect it into the rail chips so the active filters are visible. Record whether the URL
+  // carried filters so loadMe() knows whether to auto-apply the user's default saved search on load.
+  window._hadURLFilters=readURLFilters();
   syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);
   render();loadMe();
 }).catch(()=>{$('#count').textContent='failed to load deal data';});
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 61b501a..b494ee5 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -41,6 +41,7 @@ module.exports = function mountAccounts(app, ROOT) {
     // orthogonal axes, deliberately not merged (Frank is job=loan_officer AND perm=standard).
     return { email: s.email, tier: u.tier || 'free', username: u.username || null,
              name: u.name || null, role: u.role || null, industry: u.industry || null,
+             company: u.company || null,
              perm: u.perm === 'admin' ? 'admin' : 'standard' };
   }
   // Require an admin session on an admin-only route. Returns the user object, or null after
@@ -203,6 +204,33 @@ module.exports = function mountAccounts(app, ROOT) {
         db.users[fkey].perm = 'standard'; dirty = true;
       }
 
+      // Frank's firm handle (Steve 2026-08-19: "new user name Frank/Arcstone818"). Recorded as his
+      // company so it surfaces on the auth line; env-overridable. Idempotent — never clobbers a value
+      // Frank later edits himself.
+      const fcompany = String(process.env.CRCP_SEED_COMPANY || 'Arcstone818').replace(/[<>]/g, '').slice(0, 80);
+      if (db.users[fkey] && !db.users[fkey].company) { db.users[fkey].company = fcompany; dirty = true; }
+
+      // Frank's default landing filter (Steve 2026-08-19: "Onload, show 4 unit multifamily saved
+      // filter"). Seed a "4-Unit Multifamily" saved search flagged is_default so deals-flow.html
+      // auto-applies it on load when no URL filter is present. Idempotent: only seeds when Frank has
+      // NO saved searches at all, so a later user-created list is never disturbed. Filters map to the
+      // deals-flow model — Residential use (2-4 unit income property is classed Residential) with
+      // ≥4 units = the fourplex band a loan officer prospects. Reversible (delete the record).
+      db.saved[fkey] = db.saved[fkey] || [];
+      if (db.saved[fkey].length === 0) {
+        db.saved[fkey].unshift({
+          id: rid().slice(0, 12),
+          name: '4-Unit Multifamily',
+          filters: { use: ['Residential'], uMin: 4 },
+          is_default: true,
+          created: new Date().toISOString(),
+          last_seen: new Date().toISOString().slice(0, 10),
+          seeded: true,
+        });
+        dirty = true;
+        console.log('[crcp-accounts] seeded default "4-Unit Multifamily" saved search for ' + fkey);
+      }
+
       // Admin — the account-management login. Only ever auto-seeded if NO admin exists yet, so a
       // later manual demotion/reshuffle of admins is never silently re-created behind Steve's back.
       const hasAdmin = Object.values(db.users).some(u => u && u.perm === 'admin');
@@ -293,6 +321,17 @@ module.exports = function mountAccounts(app, ROOT) {
     const db = load(); db.saved[u.email] = (db.saved[u.email] || []).filter(s => s.id !== req.params.id); save(db);
     res.json({ ok: true });
   });
+  // Mark ONE saved search as the default (the one deals-flow.html auto-applies on load). Clears the
+  // flag on the user's other searches so exactly one is default. Pass id='' / 'none' to clear all.
+  app.post('/api/saved-searches/:id/default', (req, res) => {
+    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+    const db = load(); const list = db.saved[u.email] = db.saved[u.email] || [];
+    const id = req.params.id;
+    let hit = false;
+    list.forEach(s => { const on = (s.id === id); s.is_default = on; if (on) hit = true; });
+    save(db);
+    res.json({ ok: true, default: hit ? id : null });
+  });
 
   // ── watchlist (star a deal) ────────────────────────────────────────────────────
   app.get('/api/watchlist', (req, res) => { const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' }); res.json({ ids: (load().watch[u.email]) || [] }); });
diff --git a/scripts/crcp-notes.js b/scripts/crcp-notes.js
index 5a24350..222e599 100644
--- a/scripts/crcp-notes.js
+++ b/scripts/crcp-notes.js
@@ -1,12 +1,17 @@
-// crcp-notes.js — PER-USER private per-listing scratch notes for the CRCP deal-flow tool.
+// crcp-notes.js — PER-USER private scratch notes for the CRCP deal-flow tool.
 // Hybrid CRM model (Steve 2026-08-06, TK-10301): a loan officer's scratch notes on a specific
-// listing are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
+// entity are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
 // shared team asset. This module owns the notes half.
 //
-// Store: data/condo-notes.json. NEW shape is namespaced by user key:
-//   { "_perUser": true, "users": { "<userKey>": { "<listingId>": { note, updated_at } } } }
-// The OLD shape was a flat { "<listingId>": { note, updated_at } } shared by everyone. On first
-// load we auto-migrate that flat map under the FIRST user (Frank) so no note is lost.
+// SCOPES (2026-08-19): the same per-user note engine now backs two independent surfaces —
+//   • condo listings → data/condo-notes.json  (routes: /api/condo-notes[/:id])  — original, unchanged
+//   • agent/broker profiles → data/agent-notes.json (routes: /api/agent-notes[/:id]) — NEW
+// Each scope is a SEPARATE JSON file so an agent id can never collide with a condo listing id.
+//
+// Store shape (per scope file), namespaced by user key:
+//   { "_perUser": true, "users": { "<userKey>": { "<entityId>": { note, updated_at } } } }
+// The condo store may still carry the OLD flat shape { "<listingId>": { note, updated_at } } from
+// before per-user notes; on first load we auto-migrate that flat map under the FIRST user (Frank).
 //
 // Auth: notes are attributed to the signed-in account (crcp_sid session, via userOf). Reads with no
 // session return an empty set (page still renders); writes with no session return 401 so the client
@@ -18,66 +23,82 @@ const fs = require('fs');
 const path = require('path');
 
 module.exports = function mountNotes(app, ROOT, userOf) {
-  const FILE = path.join(ROOT, 'data', 'condo-notes.json');
   const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
   // Which user owns migrated legacy notes — the first/only real user at migration time.
   const FRANK = String(process.env.CRCP_SEED_USER || 'frank').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
 
-  // Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
-  function loadStore() {
-    let raw;
-    try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
-    if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
-    // Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
-    const legacy = (raw && typeof raw === 'object') ? raw : {};
-    const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
-    return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
-  }
+  // A scope = one JSON file + its route pair. `migrateLegacyFlat` only applies to the condo store
+  // (the agent store is new, so it never has a legacy flat shape to rescue).
+  function mountScope({ file, base, label, migrateLegacyFlat }) {
+    const FILE = path.join(ROOT, 'data', file);
+
+    // Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
+    function loadStore() {
+      let raw;
+      try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
+      if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
+      if (!migrateLegacyFlat) return { _perUser: true, users: {} };
+      // Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
+      const legacy = (raw && typeof raw === 'object') ? raw : {};
+      const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
+      return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
+    }
+
+    // Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
+    let _q = Promise.resolve();
+    function withStore(mutator) {
+      const run = _q.then(() => {
+        const store = loadStore();
+        const out = mutator(store);
+        const tmp = FILE + '.tmp';
+        fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
+        fs.renameSync(tmp, FILE);
+        return out;
+      });
+      _q = run.then(() => {}, () => {});
+      return run;
+    }
 
-  // Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
-  let _q = Promise.resolve();
-  function withStore(mutator) {
-    const run = _q.then(() => {
-      const store = loadStore();
-      const out = mutator(store);
-      const tmp = FILE + '.tmp';
-      fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
-      fs.renameSync(tmp, FILE);
-      return out;
+    const notesFor = (store, key) => (store.users && store.users[key]) || {};
+
+    // GET <base>  → the signed-in user's notes map. Admin may pass ?user=<key>.
+    app.get(base, (req, res) => {
+      const u = userOf(req);
+      if (!u) return res.json({ notes: {}, signed_in: false });
+      let key = u.email;
+      if (req.query.user && u.perm === 'admin') {
+        key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
+      }
+      res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
     });
-    _q = run.then(() => {}, () => {});
-    return run;
-  }
 
-  const notesFor = (store, key) => (store.users && store.users[key]) || {};
+    // POST <base>/:id { note }  → upsert (empty note deletes). Requires a session. Returns the saved
+    // record incl. updated_at so the client can render "last updated <date>" (the modification date).
+    app.post(base + '/:id', (req, res) => {
+      const u = userOf(req);
+      if (!u) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
+      const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
+      const note = clip((req.body || {}).note, 8000);
+      const key = u.email;
+      withStore(store => {
+        store.users = store.users || {};
+        const bucket = store.users[key] = store.users[key] || {};
+        if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
+        else delete bucket[id];
+        return bucket[id];
+      }).then(rec => res.json({ ok: true, id, note: rec || null }))
+        .catch(e => res.status(500).json({ error: String(e && e.message || e) }));
+    });
 
-  // GET /api/condo-notes  → the signed-in user's notes. Admin may pass ?user=<key>.
-  app.get('/api/condo-notes', (req, res) => {
-    const u = userOf(req);
-    if (!u) return res.json({ notes: {}, signed_in: false });
-    let key = u.email;
-    if (req.query.user && u.perm === 'admin') {
-      key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
-    }
-    res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
-  });
+    console.log(`[crcp-notes] ${label} mounted at ${base} (per-user, timestamped)`);
+  }
 
-  // POST /api/condo-notes/:id { note }  → upsert (empty note deletes). Requires a session.
-  app.post('/api/condo-notes/:id', (req, res) => {
-    const u = userOf(req);
-    if (!u) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
-    const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
-    const note = clip((req.body || {}).note, 8000);
-    const key = u.email;
-    withStore(store => {
-      store.users = store.users || {};
-      const bucket = store.users[key] = store.users[key] || {};
-      if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
-      else delete bucket[id];
-      return bucket[id];
-    }).then(rec => res.json({ ok: true, id, note: rec || null }))
-      .catch(e => res.status(500).json({ error: String(e && e.message || e) }));
-  });
+  // Condo listing notes — original surface, file + routes unchanged (back-compat).
+  mountScope({ file: 'condo-notes.json', base: '/api/condo-notes', label: 'condo-listing notes', migrateLegacyFlat: true });
+  // Agent / broker profile notes — NEW surface (Steve 2026-08-19: "on each agent page, provide space
+  // for NOTES and record any modification date and update"). Keyed by the profile's stable id
+  // (e.g. agent:<id|name>, broker:<id>). updated_at is the "modification date" the client shows.
+  mountScope({ file: 'agent-notes.json', base: '/api/agent-notes', label: 'agent/broker profile notes', migrateLegacyFlat: false });
 
-  console.log('[crcp-notes] per-user private listing notes mounted (hybrid CRM: private notes + shared contacts)');
+  console.log('[crcp-notes] per-user private notes mounted (hybrid CRM: private notes + shared contacts)');
 };

← 258bd68 firm-site scraper: add missing sleep() helper (pageText cras  ·  back to Commercialrealestate  ·  CRCP: 'Pocket listing' badge for off-market rows with no con 95ff71b →