[object Object]

← back to Commercialrealestate

CRCP: resolve popup double-fire — ONE row-expand panel, server-synced notes (DTD verdict A)

1b7ba1f29e1b3cb4aff5106af34e0aa91393a0df · 2026-08-20 12:27:38 -0700 · Steve

Two sessions had built competing 'click property -> detail+notes' popups on index.html.
Per Steve's DTD panel (6/6 verdict A + contrarian rider), consolidate to ONE:
- row-notes.js: notes now save to the SERVER per-user (/api/agent-notes, key prop:<id>)
  instead of localStorage, so they sync across every device Frank signs in on. Adds the
  full property field-grid + LA County assessor/sold/permit history + a 📝 icon affordance.
- index.html: removed the competing #pdOverlay block (kills the double-fire).
Auto-saves every keystroke. Verified locally: single panel, server-note persists.

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

Files touched

Diff

commit 1b7ba1f29e1b3cb4aff5106af34e0aa91393a0df
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 12:27:38 2026 -0700

    CRCP: resolve popup double-fire — ONE row-expand panel, server-synced notes (DTD verdict A)
    
    Two sessions had built competing 'click property -> detail+notes' popups on index.html.
    Per Steve's DTD panel (6/6 verdict A + contrarian rider), consolidate to ONE:
    - row-notes.js: notes now save to the SERVER per-user (/api/agent-notes, key prop:<id>)
      instead of localStorage, so they sync across every device Frank signs in on. Adds the
      full property field-grid + LA County assessor/sold/permit history + a 📝 icon affordance.
    - index.html: removed the competing #pdOverlay block (kills the double-fire).
    Auto-saves every keystroke. Verified locally: single panel, server-note persists.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html   | 173 ----------------------------------------------------
 public/row-notes.js | 161 ++++++++++++++++++++++++++++++------------------
 2 files changed, 101 insertions(+), 233 deletions(-)

diff --git a/public/index.html b/public/index.html
index 1412636..680e9c8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2492,178 +2492,5 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
        The native Fields show/hide/reorder panel (#fFields) is independent and stays. -->
   <!-- <script src="/nav-agent/nav-agent.js" defer></script> -->
 
-  <!-- ── Property detail + notes popup (Steve 2026-08-20) ──────────────────────────────
-       Click ANY property (row/card, any view) OR its 📝 icon -> a popup with ALL the
-       property's details + LA County assessor/sold/permit history + a private notes box.
-       Self-contained: reuses the shared #bkov modal, the per-user /api/agent-notes store,
-       and stamps the 📝 icon via a MutationObserver so NO page render function is edited. -->
-  <style>
-    .pdt-badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:7px;
-      border:1px solid var(--line);background:var(--bg2);color:var(--mut);font-size:13px;line-height:1;cursor:pointer;
-      flex:0 0 auto;vertical-align:middle;transition:color .15s,border-color .15s;}
-    .pdt-badge:hover{color:var(--blue);border-color:var(--blue);}
-    .pdt-badge.has{color:var(--gold);border-color:var(--gold);}
-    .card>.pdt-badge{position:absolute;top:12px;left:12px;z-index:2;}
-    td.stick .pdt-badge, .crow>.pdt-badge, .kvcard>.pdt-badge{margin-right:8px;}
-    .card,.lrow,.crow,.kvcard{cursor:pointer;}
-    #pdWrap .pdgrid{display:grid;grid-template-columns:auto 1fr;gap:5px 16px;font-size:13px;margin:12px 0;}
-    #pdWrap .pdk{color:var(--mut);white-space:nowrap;} #pdWrap .pdv{color:var(--ink);}
-    #pdWrap textarea{width:100%;min-height:96px;background:var(--bg2);border:1px solid var(--line);border-radius:10px;
-      color:var(--ink);padding:10px;font:13px/1.5 inherit;resize:vertical;}
-    #pdWrap .pdsave{margin-top:8px;display:flex;gap:10px;align-items:center;}
-    /* dedicated overlay — independent of the shared #bkov modal so nothing else can close it */
-    #pdOverlay{position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:100000;display:none;
-      align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto;}
-    #pdOverlay.open{display:flex;}
-    #pdCard{background:var(--card);border:1px solid var(--line);border-radius:15px;max-width:640px;width:100%;
-      padding:22px 24px;position:relative;box-shadow:0 24px 70px rgba(0,0,0,.55);}
-    #pdCard>.x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1;}
-    #pdCard>.x:hover{color:var(--ink);}
-    #pdWrap h3{margin:0 6px 2px 0;font-size:20px;padding-right:24px;} #pdWrap .mfirm{color:var(--acc);font-size:13px;}
-    #pdWrap .sec{margin-top:16px;} #pdWrap .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
-    #pdWrap .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} #pdWrap .litem .m{color:var(--mut);}
-    #pdWrap .ci{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;} #pdWrap .gate{color:var(--mut);font-size:13px;}
-    #pdWrap .primary{background:var(--blue);color:#fff;border:0;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px;}
-  </style>
-  <script>
-  (function(){
-    var esc=function(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});};
-    var money=function(n){return (n==null||n==='')?'—':'$'+Number(n).toLocaleString();};
-    var keyOf=function(id){return 'prop:'+id;};
-    // DATA is a top-level `let` (shared across classic scripts by bare name, NOT on window).
-    var findProp=function(id){try{return ((typeof DATA!=='undefined'&&DATA&&DATA.ranked)||[]).find(function(x){return String(x.id)===String(id);});}catch(_){return null;}};
-    var notesSet=new Set();
-    function refreshNotesSet(){return fetch('/api/agent-notes',{headers:{Accept:'application/json'}}).then(function(r){return r.ok?r.json():{notes:{}};})
-      .then(function(j){notesSet=new Set(Object.keys(j.notes||{}).filter(function(k){return j.notes[k]&&j.notes[k].note&&j.notes[k].note.trim();}));return notesSet;}).catch(function(){return notesSet;});}
-
-    // ---- the popup ----
-    function detailRows(p){
-      var ppu=(p.price&&p.units)?Math.round(p.price/p.units):null;
-      var ppsf=(p.price&&p.sqft)?Math.round(p.price/p.sqft):null;
-      var f=p.finance||{};
-      var rows=[
-        ['Type',p.type],['Price',money(p.price)],['Units',p.units],
-        ['$/Unit',ppu?money(ppu):null],['Sqft',p.sqft?Number(p.sqft).toLocaleString():null],['$/SF',ppsf?money(ppsf):null],
-        ['Cap rate',(p.cap_rate!=null&&p.cap_rate!=='')?p.cap_rate+'%':null],
-        ['Cash-on-cash',(f.coc!=null)?f.coc+'%':null],['DSCR',(f.dscr!=null)?f.dscr:null],
-        ['Year built',p.year_built],['Status',p.status],
-        ['Deal score',(p.deal&&p.deal.score!=null)?p.deal.score:null],
-        ['Investment score',(p.composite!=null)?p.composite:(p.financeScore!=null?p.financeScore:null)],
-        ['Firm',p.firm||p.broker_firm],['Broker phone',p.broker_phone],
-        ['Source',p.source_host||p.source],['Zip',p.zip]
-      ];
-      var html=rows.filter(function(r){return r[1]!=null&&r[1]!=='';})
-        .map(function(r){return '<div class="pdk">'+esc(r[0])+'</div><div class="pdv">'+esc(r[1])+'</div>';}).join('');
-      // agent link(s)
-      var ags=p.broker_agents; if(Array.isArray(ags)&&ags.length){
-        var links=ags.slice(0,4).map(function(a){var n=(a&&a.name)||a; return '<a href="/agent.html?name='+encodeURIComponent(n)+'" style="color:var(--blue);text-decoration:none">'+esc(n)+'</a>';}).join(', ');
-        html+='<div class="pdk">Agent</div><div class="pdv">'+links+'</div>';
-      }
-      return html;
-    }
-    function histHtml(d){
-      var money2=function(n){return n?'$'+Number(n).toLocaleString():'—';};
-      var a=d.assessor||d.assessorArchive, h='';
-      if(a){h+='<div class="ci">'
-        +(a.year_built?'<span>🏗 Built '+esc(a.year_built)+'</span>':'')
-        +(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')
-        +(a.units?'<span>🏠 '+esc(a.units)+' units</span>':'')
-        +((a.roll_total_value||a.total_value)?'<span>💰 assessed '+money2(a.roll_total_value||a.total_value)+'</span>':'')+'</div>';}
-      var sales=d.sales||[];
-      h+='<div style="margin-top:6px"><b style="font-size:12px;color:var(--mut)">Sold history ('+sales.length+')</b>'
-        +(sales.length?sales.map(function(x){return '<div class="litem"><span>'+esc(x.address||'')+'</span><span class="m">'+money2(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+'</span></div>';}).join(''):'<div class="gate">no sold records on file</div>')+'</div>';
-      if(d.permits&&d.permits.length)h+='<div style="margin-top:6px"><b style="font-size:12px;color:var(--mut)">Permits ('+d.permits.length+')</b>'
-        +d.permits.slice(0,20).map(function(pm){return '<div class="litem"><span>'+esc(pm.permit_type||pm.use_description||'permit')+'</span><span class="m">'+esc(String(pm.issue_date||'').slice(0,10))+(pm.valuation?' · '+money2(pm.valuation):'')+'</span></div>';}).join('')+'</div>';
-      if(!a&&!sales.length&&!(d.permits&&d.permits.length))h='<div class="gate">No LA County assessor / sold / permit record matched this address.</div>';
-      return h;
-    }
-    function ensureOverlay(){
-      var ov=document.getElementById('pdOverlay');
-      if(!ov){
-        ov=document.createElement('div'); ov.id='pdOverlay';
-        ov.innerHTML='<div id="pdCard"><button class="x" type="button" aria-label="Close">✕</button><div id="pdBody"></div></div>';
-        document.body.appendChild(ov);
-        var close=function(){ ov.classList.remove('open'); };
-        ov.addEventListener('click',function(e){ if(e.target===ov) close(); });
-        ov.querySelector('.x').addEventListener('click',close);
-        document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
-      }
-      return document.getElementById('pdBody');
-    }
-    window.openProperty=function(p){
-      if(!p) return;
-      var mb=ensureOverlay();
-      document.getElementById('pdOverlay').classList.add('open');
-      var key=keyOf(p.id);
-      mb.innerHTML='<div id="pdWrap"><h3>'+esc(p.address||'Property')+'</h3>'
-        +'<div class="mfirm">'+esc(p.city||'')+(p.zip?' '+esc(p.zip):'')+(p.type?' · '+esc(p.type):'')+'</div>'
-        +'<div class="pdgrid">'+detailRows(p)+'</div>'
-        +'<div class="sec"><h4>Notes (private to you)</h4><textarea id="pdNote" placeholder="Leave a note on this property…"></textarea>'
-        +'<div class="pdsave"><button id="pdSave" class="primary">💾 Save note</button><span id="pdStat" class="gate"></span></div></div>'
-        +'<div class="sec"><h4>LA County history</h4><div id="pdHist" class="gate">loading assessor · sold · permits…</div></div></div>';
-      fetch('/api/agent-notes',{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(j){
-        var t=document.getElementById('pdNote'); if(t&&j.notes&&j.notes[key])t.value=j.notes[key].note||'';
-      }).catch(function(){});
-      var save=document.getElementById('pdSave');
-      if(save)save.onclick=function(){
-        var t=document.getElementById('pdNote'), st=document.getElementById('pdStat'); if(!t)return;
-        st.textContent='saving…';
-        fetch('/api/agent-notes/'+encodeURIComponent(key),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({note:t.value})})
-          .then(function(r){return r.json();}).then(function(){
-            st.textContent='saved ✓';
-            if(t.value.trim())notesSet.add(key); else notesSet.delete(key);
-            var b=document.querySelector('.pdt-badge[data-id="'+p.id+'"]'); if(b)b.classList.toggle('has',!!t.value.trim());
-            setTimeout(function(){st.textContent='';},1500);
-          }).catch(function(){st.textContent='error saving';});
-      };
-      fetch('/api/property-history?address='+encodeURIComponent(p.address||'')+'&city='+encodeURIComponent(p.city||''))
-        .then(function(r){return r.json();}).then(function(d){var el=document.getElementById('pdHist'); if(el)el.innerHTML=histHtml(d);})
-        .catch(function(){var el=document.getElementById('pdHist'); if(el)el.textContent='history unavailable';});
-    };
-
-    // ---- 📝 icon stamping (MutationObserver — no render-fn edits) ----
-    function badge(id){
-      var b=document.createElement('button');
-      b.className='pdt-badge'+(notesSet.has(keyOf(id))?' has':''); b.type='button';
-      b.setAttribute('data-id',id); b.title='Details & notes'; b.textContent='📝';
-      return b;
-    }
-    function stampAll(){
-      // cards
-      document.querySelectorAll('.card:not([data-pdt])').forEach(function(el){
-        el.setAttribute('data-pdt','1'); var id=(el.id||'').indexOf('card-')===0?el.id.slice(5):el.getAttribute('data-id');
-        if(id){ el.appendChild(badge(id)); }
-      });
-      // list rows -> into the sticky/first cell
-      document.querySelectorAll('.lrow:not([data-pdt])').forEach(function(el){
-        el.setAttribute('data-pdt','1'); var id=el.getAttribute('data-id'); if(!id)return;
-        var cell=el.querySelector('td.stick')||el.querySelector('td'); if(cell)cell.insertBefore(badge(id),cell.firstChild);
-      });
-      // compact rows + kv cards
-      document.querySelectorAll('.crow:not([data-pdt]),.kvcard:not([data-pdt])').forEach(function(el){
-        el.setAttribute('data-pdt','1'); var id=el.getAttribute('data-id'); if(id)el.insertBefore(badge(id),el.firstChild);
-      });
-    }
-    var raf=null;
-    function scheduleStamp(){ if(raf)return; raf=requestAnimationFrame(function(){raf=null;stampAll();}); }
-
-    function boot(){
-      var grid=document.getElementById('grid'); if(!grid)return;
-      // delegated open: the 📝 badge, or a plain row/card click (not on an existing control)
-      grid.addEventListener('click',function(e){
-        var b=e.target.closest('.pdt-badge');
-        if(b){ e.preventDefault(); e.stopPropagation(); openProperty(findProp(b.getAttribute('data-id'))); return; }
-        if(e.target.closest('a,button,input,select,textarea,label,[data-star],[data-copy],[data-quick],[data-sortk],.histbtn,.lcbtn,#showmore')) return;
-        var row=e.target.closest('.card,.lrow,.crow,.kvcard'); if(!row)return;
-        var id=row.getAttribute('data-id'); if(!id&&(row.id||'').indexOf('card-')===0)id=row.id.slice(5);
-        if(id)openProperty(findProp(id));
-      });
-      refreshNotesSet().then(scheduleStamp);
-      new MutationObserver(scheduleStamp).observe(grid,{childList:true,subtree:true});
-      scheduleStamp();
-    }
-    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot); else boot();
-  })();
-  </script>
 </body>
 </html>
diff --git a/public/row-notes.js b/public/row-notes.js
index 878ed2b..a43449f 100644
--- a/public/row-notes.js
+++ b/public/row-notes.js
@@ -1,90 +1,110 @@
-/* row-notes.js — CRCP list rows: click anywhere in the row's OPEN SPACE to expand a consolidated
- * detail panel with PRIVATE per-user notes. Notes live in this browser's localStorage only — they
- * are NOT shared with other users or sent to any server. Additive drop-in; no row-render changes.
- * (Steve 8/20.) */
+/* row-notes.js — CRCP list rows: click a row (or its 📝 icon) to expand ONE consolidated detail
+ * panel with the property's full details, LA County assessor/sold/permit history, and PRIVATE
+ * per-user notes. Notes save to the SERVER per-user (/api/agent-notes, key "prop:<id>") so they
+ * SYNC across every device the user signs in on — NOT localStorage. Single click handler (no
+ * competing overlay). (Steve 8/20; server-notes + 📝 icon + history merge per DTD verdict A.) */
 (function () {
   'use strict';
-  var NOTE_KEY = function (id) { return 'crcp_note_' + id; };
-  var OPEN = new Set();                                   // ids currently expanded (this page load)
+  var NOTE_KEY = function (id) { return 'prop:' + id; };
+  var OPEN = new Set();
+  var NOTES = null;                                        // server cache {key:{note,updated_at}}, null until loaded
+  var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
+  var money = function (n) { return (n == null || n === '') ? '—' : '$' + Number(n).toLocaleString(); };
 
-  // A row we can expand: has data-id and is a list/table/kv/card row (not a header, not the compare table).
-  function rowFor(el) {
-    var r = el.closest('.crow[data-id], .kvcard[data-id], .listtbl tr[data-id], tr.mrow[data-addr], .card[data-id], .lead[data-id]');
-    return r;
-  }
-  // Don't hijack clicks on real interactive bits (links, buttons, inputs, the row's own controls).
-  function isInteractive(el) {
-    return !!el.closest('a, button, input, textarea, select, label, [onclick], .lcbtn, .histbtn, .hist, .cm-star, .chip');
+  function loadNotes() {
+    return fetch('/api/agent-notes', { headers: { Accept: 'application/json' } })
+      .then(function (r) { return r.ok ? r.json() : { notes: {} }; })
+      .then(function (j) { NOTES = j.notes || {}; return NOTES; })
+      .catch(function () { NOTES = {}; return NOTES; });
   }
+  // DATA is a top-level `let` in index.html's inline script — reachable by bare name from this
+  // deferred classic script at click time (runs after the DOM + that inline script).
+  var findProp = function (id) { try { return ((typeof DATA !== 'undefined' && DATA && DATA.ranked) || []).find(function (x) { return String(x.id) === String(id); }); } catch (_) { return null; } };
 
-  function rowId(row) {
-    return row.getAttribute('data-id') || row.getAttribute('data-addr') || '';
-  }
+  function rowFor(el) { return el.closest('.crow[data-id], .kvcard[data-id], .listtbl tr[data-id], tr.mrow[data-addr], .card[data-id], .lead[data-id]'); }
+  function isInteractive(el) { return !!el.closest('a, button:not(.rn-badge), input, textarea, select, label, [onclick], .lcbtn, .histbtn, .hist, .cm-star, .chip'); }
+  function rowId(row) { return row.getAttribute('data-id') || row.getAttribute('data-addr') || ''; }
 
-  // Consolidate the row into label:value pairs for the detail panel.
-  function consolidate(row) {
-    var out = [];
-    var cells = row.querySelectorAll(':scope > td, :scope > .cell, :scope .kv, :scope .metric');
-    // table rows: pair <td> to the table's <th> headers when available
-    var ths = [];
-    var tbl = row.closest('table');
-    if (tbl) { tbl.querySelectorAll('thead th').forEach(function (th) { ths.push(th.textContent.trim()); }); }
+  // Detail pairs — prefer the real property object; fall back to consolidating cell text.
+  function detailPairs(row, p) {
+    if (p) {
+      var ppu = (p.price && p.units) ? Math.round(p.price / p.units) : null;
+      var ppsf = (p.price && p.sqft) ? Math.round(p.price / p.sqft) : null;
+      var f = p.finance || {};
+      return [
+        ['Type', p.type], ['Price', money(p.price)], ['Units', p.units],
+        ['$/Unit', ppu ? money(ppu) : null], ['Sqft', p.sqft ? Number(p.sqft).toLocaleString() : null], ['$/SF', ppsf ? money(ppsf) : null],
+        ['Cap rate', (p.cap_rate != null && p.cap_rate !== '') ? p.cap_rate + '%' : null], ['Cash-on-cash', (f.coc != null) ? f.coc + '%' : null],
+        ['Year built', p.year_built], ['Status', p.status], ['Deal score', (p.deal && p.deal.score != null) ? p.deal.score : null],
+        ['Firm', p.firm || p.broker_firm], ['Broker phone', p.broker_phone], ['Agent', (p.broker_agents || []).join(', ') || null], ['Zip', p.zip]
+      ].filter(function (r) { return r[1] != null && r[1] !== ''; });
+    }
+    var out = [], tbl = row.closest('table'), ths = [];
+    if (tbl) tbl.querySelectorAll('thead th').forEach(function (th) { ths.push(th.textContent.trim()); });
     if (row.tagName === 'TR') {
-      row.querySelectorAll(':scope > td').forEach(function (td, i) {
-        var label = ths[i] || ('Field ' + (i + 1));
-        var val = td.textContent.replace(/\s+/g, ' ').trim();
-        if (val) out.push([label, val]);
-      });
+      row.querySelectorAll(':scope > td').forEach(function (td, i) { var v = td.textContent.replace(/\s+/g, ' ').trim(); if (v) out.push([ths[i] || ('Field ' + (i + 1)), v]); });
     } else {
-      // card/compact/kv: just show the visible text lines
-      row.querySelectorAll('.deeplbl, .kvk, .mk').forEach(function (k) {
-        var v = k.nextElementSibling ? k.nextElementSibling.textContent.trim() : '';
-        if (v) out.push([k.textContent.trim(), v]);
-      });
+      row.querySelectorAll('.deeplbl, .kvk, .mk').forEach(function (k) { var v = k.nextElementSibling ? k.nextElementSibling.textContent.trim() : ''; if (v) out.push([k.textContent.trim(), v]); });
       if (!out.length) out.push(['Listing', row.textContent.replace(/\s+/g, ' ').trim().slice(0, 240)]);
     }
     return out;
   }
 
-  function panelHTML(row, id) {
-    var esc = function (s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); };
-    var pairs = consolidate(row).map(function (p) {
-      return '<div class="rn-kv"><span class="rn-k">' + esc(p[0]) + '</span><span class="rn-v">' + esc(p[1]) + '</span></div>';
-    }).join('');
-    var note = '';
-    try { note = localStorage.getItem(NOTE_KEY(id)) || ''; } catch (e) {}
+  function panelHTML(row, id, p) {
+    var pairs = detailPairs(row, p).map(function (pr) { return '<div class="rn-kv"><span class="rn-k">' + esc(pr[0]) + '</span><span class="rn-v">' + esc(pr[1]) + '</span></div>'; }).join('');
+    var note = (NOTES && NOTES[NOTE_KEY(id)] && NOTES[NOTE_KEY(id)].note) || '';
     return '<div class="rn-panel" data-rn="' + esc(id) + '">'
       + '<div class="rn-grid">' + pairs + '</div>'
-      + '<div class="rn-notes"><label class="rn-notelbl">📝 My private notes <span class="rn-priv">(only you — stored on this device, never shared)</span></label>'
+      + (p ? '<div class="rn-hist" data-hist="' + esc(id) + '"><span class="rn-priv">loading LA County history…</span></div>' : '')
+      + '<div class="rn-notes"><label class="rn-notelbl">📝 My private notes <span class="rn-priv">(private to you — syncs across your devices)</span></label>'
       + '<textarea class="rn-ta" placeholder="Jot a private note about this listing…">' + esc(note) + '</textarea>'
       + '<div class="rn-saved" aria-live="polite"></div></div></div>';
   }
 
+  function histHTML(d) {
+    var a = d.assessor || d.assessorArchive, h = '';
+    if (a) {
+      h += '<div class="rn-ci">'
+        + (a.year_built ? '<span>🏗 Built ' + esc(a.year_built) + '</span>' : '')
+        + (a.sqft_main ? '<span>📐 ' + Number(a.sqft_main).toLocaleString() + ' sqft</span>' : '')
+        + (a.units ? '<span>🏠 ' + esc(a.units) + ' units</span>' : '')
+        + ((a.roll_total_value || a.total_value) ? '<span>💰 assessed ' + money(a.roll_total_value || a.total_value) + '</span>' : '') + '</div>';
+    }
+    var sales = d.sales || [];
+    h += '<div class="rn-hsec"><b>Sold history (' + sales.length + ')</b>' + (sales.length ? sales.map(function (x) { return '<div class="rn-hrow"><span>' + esc(x.address || '') + '</span><span>' + money(x.sold_price) + ' · ' + String(x.sold_date || '').slice(0, 10) + '</span></div>'; }).join('') : '<span class="rn-priv"> none on file</span>') + '</div>';
+    if (d.permits && d.permits.length) h += '<div class="rn-hsec"><b>Permits (' + d.permits.length + ')</b>' + d.permits.slice(0, 15).map(function (pm) { return '<div class="rn-hrow"><span>' + esc(pm.permit_type || pm.use_description || 'permit') + '</span><span>' + esc(String(pm.issue_date || '').slice(0, 10)) + '</span></div>'; }).join('') + '</div>';
+    if (!a && !sales.length && !(d.permits && d.permits.length)) h = '<span class="rn-priv">No LA County assessor / sold / permit record matched this address.</span>';
+    return h;
+  }
+
   function insertPanel(row) {
     var id = rowId(row); if (!id) return;
-    var host, td;
+    var p = findProp(id), host;
     if (row.tagName === 'TR') {
-      // add a full-width detail row directly beneath
-      var cols = row.children.length || 1;
-      var tr = document.createElement('tr');
+      var cols = row.children.length || 1, tr = document.createElement('tr');
       tr.className = 'rn-row'; tr.setAttribute('data-rn-for', id);
-      td = document.createElement('td'); td.colSpan = cols; td.style.padding = '0';
-      td.innerHTML = panelHTML(row, id); tr.appendChild(td);
-      row.after(tr); host = tr;
+      var td = document.createElement('td'); td.colSpan = cols; td.style.padding = '0';
+      td.innerHTML = panelHTML(row, id, p); tr.appendChild(td); row.after(tr); host = tr;
     } else {
-      var div = document.createElement('div');
-      div.className = 'rn-row'; div.setAttribute('data-rn-for', id);
-      div.innerHTML = panelHTML(row, id);
-      row.after(div); host = div;
+      var div = document.createElement('div'); div.className = 'rn-row'; div.setAttribute('data-rn-for', id);
+      div.innerHTML = panelHTML(row, id, p); row.after(div); host = div;
     }
     row.classList.add('rn-open');
-    // wire the notes textarea → localStorage (debounced), private
-    var ta = host.querySelector('.rn-ta'); var saved = host.querySelector('.rn-saved'); var t;
+    var ta = host.querySelector('.rn-ta'), saved = host.querySelector('.rn-saved'), t;
+    // backfill the note if the cache wasn't ready when the panel rendered
+    if (NOTES === null && ta) loadNotes().then(function () { var n = NOTES[NOTE_KEY(id)]; if (n && n.note && !ta.value) ta.value = n.note; });
     if (ta) ta.addEventListener('input', function () {
       clearTimeout(t); t = setTimeout(function () {
-        try { localStorage.setItem(NOTE_KEY(id), ta.value); saved.textContent = 'saved ' + new Date().toLocaleTimeString(); } catch (e) { saved.textContent = 'could not save'; }
-      }, 250);
+        saved.textContent = 'saving…';
+        fetch('/api/agent-notes/' + encodeURIComponent(NOTE_KEY(id)), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note: ta.value }) })
+          .then(function (r) { return r.json(); })
+          .then(function () { if (NOTES) NOTES[NOTE_KEY(id)] = { note: ta.value }; saved.textContent = 'saved ' + new Date().toLocaleTimeString(); markBadge(id, !!ta.value.trim()); })
+          .catch(function () { saved.textContent = 'could not save'; });
+      }, 400);
     });
+    var histEl = host.querySelector('.rn-hist');
+    if (histEl && p) fetch('/api/property-history?address=' + encodeURIComponent(p.address || '') + '&city=' + encodeURIComponent(p.city || ''))
+      .then(function (r) { return r.json(); }).then(function (d) { histEl.innerHTML = histHTML(d); }).catch(function () { histEl.innerHTML = '<span class="rn-priv">history unavailable</span>'; });
     OPEN.add(id);
   }
   function removePanel(row) {
@@ -95,21 +115,42 @@
   }
 
   document.addEventListener('click', function (e) {
+    var badge = e.target.closest('.rn-badge');
+    if (badge) { e.preventDefault(); e.stopPropagation(); var r = rowFor(badge) || badge.closest('[data-id],[data-addr]'); if (r) { if (r.classList.contains('rn-open')) removePanel(r); else insertPanel(r); } return; }
     if (isInteractive(e.target)) return;
-    if (e.target.closest('.rn-panel')) return;           // clicks inside the panel don't collapse it
+    if (e.target.closest('.rn-panel')) return;
     var row = rowFor(e.target); if (!row) return;
     if (row.classList.contains('rn-open')) removePanel(row); else insertPanel(row);
   });
 
-  // styles (theme-aware via the page's CSS vars, with fallbacks)
+  // 📝 icon affordance on every row + a gold marker when a note exists.
+  function badgeEl(id) { var b = document.createElement('button'); b.type = 'button'; b.className = 'rn-badge' + ((NOTES && NOTES[NOTE_KEY(id)] && NOTES[NOTE_KEY(id)].note && NOTES[NOTE_KEY(id)].note.trim()) ? ' has' : ''); b.title = 'Details & notes'; b.textContent = '📝'; b.setAttribute('data-id', id); return b; }
+  function markBadge(id, has) { var b = document.querySelector('.rn-badge[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]'); if (b) b.classList.toggle('has', !!has); }
+  function stampAll() {
+    document.querySelectorAll('.card[data-id]:not([data-rnb]), .crow[data-id]:not([data-rnb]), .kvcard[data-id]:not([data-rnb])').forEach(function (el) { el.setAttribute('data-rnb', '1'); var id = el.getAttribute('data-id'); if (id) el.insertBefore(badgeEl(id), el.firstChild); });
+    document.querySelectorAll('.listtbl tr[data-id]:not([data-rnb])').forEach(function (el) { el.setAttribute('data-rnb', '1'); var id = el.getAttribute('data-id'); if (!id) return; var cell = el.querySelector('td.stick') || el.querySelector('td'); if (cell) cell.insertBefore(badgeEl(id), cell.firstChild); });
+  }
+  var raf = null; function schedule() { if (raf) return; raf = requestAnimationFrame(function () { raf = null; stampAll(); }); }
+  var grid = document.getElementById('grid');
+  if (grid) new MutationObserver(schedule).observe(grid, { childList: true, subtree: true });
+  loadNotes().then(schedule); schedule();
+
   var css = document.createElement('style');
   css.textContent =
     '.crow[data-id],.kvcard[data-id],.listtbl tr[data-id],tr.mrow[data-addr],.card[data-id]{cursor:pointer}'
+    + '.card[data-id]{position:relative}.card>.rn-badge{position:absolute;top:12px;left:12px;z-index:2}'
+    + '.rn-badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:7px;border:1px solid var(--line,#e3e6ea);background:var(--bg2,#f7f8fa);color:var(--mut,#8a94a3);font-size:13px;line-height:1;cursor:pointer;flex:0 0 auto;vertical-align:middle;margin-right:8px}'
+    + '.rn-badge:hover{color:var(--blue,#2f6df6);border-color:var(--blue,#2f6df6)}'
+    + '.rn-badge.has{color:var(--gold,#d9b25f);border-color:var(--gold,#d9b25f)}'
     + '.rn-panel{background:var(--card,#f7f8fa);border:1px solid var(--line,#e3e6ea);border-top:none;border-radius:0 0 10px 10px;padding:12px 16px;margin:0 0 4px}'
     + '.rn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 18px;margin-bottom:10px}'
     + '.rn-kv{display:flex;gap:8px;font-size:12.5px;line-height:1.5;border-bottom:1px dotted var(--line,#e3e6ea);padding:2px 0}'
     + '.rn-k{color:var(--mut,#8a94a3);min-width:92px;text-transform:uppercase;font-size:10.5px;letter-spacing:.03em;padding-top:1px}'
     + '.rn-v{color:var(--ink,#1c2530);font-weight:500}'
+    + '.rn-hist{margin:0 0 10px}'
+    + '.rn-ci{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--ink,#1c2530);margin-bottom:6px}'
+    + '.rn-hsec{margin-top:6px;font-size:12px}.rn-hsec b{color:var(--mut,#8a94a3);font-size:10.5px;text-transform:uppercase;letter-spacing:.03em}'
+    + '.rn-hrow{display:flex;justify-content:space-between;gap:10px;padding:2px 0;border-bottom:1px dotted var(--line,#e3e6ea);color:var(--ink,#1c2530)}'
     + '.rn-notelbl{display:block;font-size:12px;color:var(--ink,#1c2530);margin-bottom:5px}'
     + '.rn-priv{color:var(--mut,#8a94a3);font-weight:400;font-size:11px}'
     + '.rn-ta{width:100%;box-sizing:border-box;min-height:66px;background:var(--bg,#fff);border:1px solid var(--line,#e3e6ea);border-radius:8px;color:var(--ink,#1c2530);font:inherit;font-size:12.5px;padding:8px 10px;resize:vertical;line-height:1.5}'

← 0938efc CRCP index: fix overlapping UI — bar controls no longer spil  ·  back to Commercialrealestate  ·  CRCP: recities.crcp hub — city launcher dropdown of all 50 m 7f18f17 →