[object Object]

← back to Commercialrealestate

user-badge: Saved Searches menu under the user-name icon — per-user server-backed (/api/saved-searches), snapshots the checked-field layout (cre_fields+filters+navagent) and restores it, name/list/restore/delete; appears on ?v=list + every page via the injected badge (no page-file edits); v0.26.0

3f9c93dd566ae1f85f52cb3a9cf4d58a1c8368ca · 2026-08-26 11:42:01 -0700 · Steve

Files touched

Diff

commit 3f9c93dd566ae1f85f52cb3a9cf4d58a1c8368ca
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 26 11:42:01 2026 -0700

    user-badge: Saved Searches menu under the user-name icon — per-user server-backed (/api/saved-searches), snapshots the checked-field layout (cre_fields+filters+navagent) and restores it, name/list/restore/delete; appears on ?v=list + every page via the injected badge (no page-file edits); v0.26.0
---
 package-lock.json    |   4 +-
 package.json         |   2 +-
 public/user-badge.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 141 insertions(+), 4 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index ba20102..fee7b5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "commercialrealestate",
-  "version": "0.25.1",
+  "version": "0.26.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "commercialrealestate",
-      "version": "0.25.1",
+      "version": "0.26.0",
       "dependencies": {
         "better-sqlite3": "^12.11.1",
         "express": "^4.22.2",
diff --git a/package.json b/package.json
index 9fd9a67..cad439f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "commercialrealestate",
-  "version": "0.25.1",
+  "version": "0.26.0",
   "private": true,
   "description": "LA County CRE investment explorer — multi-firm sourced, Census + assessor enriched, Qwen-analyzed",
   "scripts": {
diff --git a/public/user-badge.js b/public/user-badge.js
index 8366c84..aa1d7d9 100644
--- a/public/user-badge.js
+++ b/public/user-badge.js
@@ -20,6 +20,53 @@
     });
   };
 
+  // localStorage keys (plus every `navagent:` key) that together define a page's layout+filters,
+  // so a saved search restores the exact view — the field-visibility set (cre_fields), column order,
+  // view/group/density, and the filter/scenario/heat state. Filters also live in the URL (qs).
+  var LAYOUT_KEYS = ['cre_fields', 'idxColOrder', 'cre_view', 'cre_group', 'cre_density',
+    'cre_filters', 'cre_scenario', 'cre_heat', 'cre_shortlist'];
+
+  // Snapshot the current page's restorable state: path + querystring (filters sync to the URL) plus
+  // the layout localStorage keys (field visibility = cre_fields, column order, view/group/density…)
+  // and every `navagent:` key. Stored verbatim inside a saved search's `filters` object.
+  function captureSnapshot() {
+    var layout = {};
+    try {
+      LAYOUT_KEYS.forEach(function (k) { var v = localStorage.getItem(k); if (v != null) layout[k] = v; });
+      for (var i = 0; i < localStorage.length; i++) {
+        var key = localStorage.key(i);
+        if (key && key.indexOf('navagent:') === 0) { var val = localStorage.getItem(key); if (val != null) layout[key] = val; }
+      }
+    } catch (e) {}
+    return { v: 1, path: location.pathname, qs: location.search || '', layout: layout };
+  }
+
+  // Restore a saved search's `filters`. New records = {v,path,qs,layout}; legacy records (e.g. the
+  // seeded {use,uMin} deals-flow default) carry no navigable target → do nothing destructive and
+  // return false so the caller can tell the user. Writes layout keys back, then navigates/reloads.
+  function restoreSnapshot(f) {
+    if (!f || typeof f !== 'object') return false;
+    if (f.layout && typeof f.layout === 'object') {
+      try { Object.keys(f.layout).forEach(function (k) { try { localStorage.setItem(k, String(f.layout[k])); } catch (e) {} }); } catch (e) {}
+    }
+    var hasPath = typeof f.path === 'string', hasQs = typeof f.qs === 'string';
+    if (!hasPath && !hasQs) return false;
+    var target = (hasPath ? f.path : location.pathname) + (hasQs ? f.qs : '');
+    var cur = location.pathname + (location.search || '');
+    if (target === cur) location.reload(); else location.assign(target);
+    return true;
+  }
+
+  function savedRowsHtml(arr) {
+    return arr.map(function (s) {
+      var star = s.is_default ? '<span class="ssdef" title="Default">★</span>' : '';
+      return '<div class="ssrow" data-id="' + esc(s.id) + '">' +
+        '<button class="ssname" type="button" title="Restore this view">' + star + '📁 ' + esc(s.name) + '</button>' +
+        '<button class="ssdel" type="button" title="Delete saved search" aria-label="Delete saved search">✕</button>' +
+      '</div>';
+    }).join('');
+  }
+
   var PERSON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" ' +
     'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
     '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>';
@@ -48,7 +95,23 @@
       '#crcp-ub .divider{height:1px;background:#232c3b;margin:10px -14px}' +
       '#crcp-ub .out{display:block;width:100%;text-align:left;background:none;border:0;color:#3b7dff;' +
       'font-size:13px;cursor:pointer;padding:2px 0}' +
-      '#crcp-ub .out:hover{text-decoration:underline}';
+      '#crcp-ub .out:hover{text-decoration:underline}' +
+      '#crcp-ub .ss-h{color:#d9b25f;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin:2px 0 6px}' +
+      '#crcp-ub .sslist{max-height:190px;overflow-y:auto;margin:0 -4px 8px;padding:0 4px}' +
+      '#crcp-ub .ssrow{display:flex;align-items:center;gap:6px;margin-bottom:4px}' +
+      '#crcp-ub .ssname{flex:1;min-width:0;text-align:left;background:#0e131c;border:1px solid #232c3b;color:#eef2f8;' +
+      'border-radius:8px;padding:6px 9px;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
+      '#crcp-ub .ssname:hover{border-color:#d9b25f;color:#d9b25f}' +
+      '#crcp-ub .ssdef{color:#d9b25f;margin-right:4px}' +
+      '#crcp-ub .ssdel{background:none;border:0;color:#8b97a8;cursor:pointer;font-size:13px;line-height:1;padding:2px 4px;flex:0 0 auto}' +
+      '#crcp-ub .ssdel:hover{color:#ff6b6b}' +
+      '#crcp-ub .ssdel:disabled{opacity:.4;cursor:default}' +
+      '#crcp-ub .ssempty{color:#8b97a8;font-size:11px;padding:2px 0}' +
+      '#crcp-ub .ssmsg{color:#ffb27a;font-size:11px;padding:2px 0}' +
+      '#crcp-ub .sssave{display:block;width:100%;text-align:left;background:none;border:0;color:#3b7dff;' +
+      'font-size:13px;cursor:pointer;padding:2px 0}' +
+      '#crcp-ub .sssave:hover{text-decoration:underline}' +
+      '#crcp-ub .sssave:disabled{opacity:.5;cursor:default;text-decoration:none}';
     var s = document.createElement('style'); s.id = 'crcp-ub-style'; s.textContent = css;
     document.head.appendChild(s);
   }
@@ -73,14 +136,88 @@
         (role ? '<div class="mline">' + esc(role) + '</div>' : '') +
         '<div class="mline">' + esc(me.email || me.username) + '</div>' +
         '<div class="divider"></div>' +
+        '<div class="ss-h">Saved Searches</div>' +
+        '<div class="sslist" id="crcp-ss-list"><div class="ssempty">Loading…</div></div>' +
+        '<button class="sssave" type="button">💾 Save current view</button>' +
+        '<div class="ssmsg" id="crcp-ss-msg" style="display:none"></div>' +
+        '<div class="divider"></div>' +
         '<button class="out" type="button">Sign out</button>' +
       '</div>';
     document.body.appendChild(wrap);
 
     var pill = wrap.querySelector('.pill');
+
+    // ── Saved Searches wiring (per-user, server-backed via /api/saved-searches) ──
+    var ssList = wrap.querySelector('#crcp-ss-list');
+    var ssSaveBtn = wrap.querySelector('.sssave');
+    var ssMsg = wrap.querySelector('#crcp-ss-msg');
+    var savedCache = [];
+    var ssLoaded = false, ssMsgTimer = null;
+    function ssFlash(text) {
+      if (!ssMsg) return;
+      ssMsg.textContent = text; ssMsg.style.display = 'block';
+      if (ssMsgTimer) clearTimeout(ssMsgTimer);
+      ssMsgTimer = setTimeout(function () { ssMsg.style.display = 'none'; }, 2600);
+    }
+    function ssPaint() {
+      ssList.innerHTML = savedCache.length ? savedRowsHtml(savedCache)
+        : '<div class="ssempty">None yet — save your current view.</div>';
+    }
+    function ssRefresh() {
+      ssList.innerHTML = '<div class="ssempty">Loading…</div>';
+      fetch('/api/saved-searches', { headers: { Accept: 'application/json' } })
+        .then(function (r) { return r.ok ? r.json() : null; })
+        .then(function (j) {
+          if (!j || !Array.isArray(j.saved)) { ssList.innerHTML = '<div class="ssmsg">Couldn’t load saved searches.</div>'; savedCache = []; return; }
+          savedCache = j.saved; ssPaint();
+        })
+        .catch(function () { ssList.innerHTML = '<div class="ssmsg">Couldn’t load saved searches.</div>'; });
+    }
+    if (ssList) {
+      ssList.addEventListener('click', function (e) {
+        e.stopPropagation();
+        var del = e.target.closest('.ssdel');
+        if (del) {
+          var drow = del.closest('.ssrow'); var did = drow && drow.getAttribute('data-id'); if (!did) return;
+          del.disabled = true;
+          fetch('/api/saved-searches/' + encodeURIComponent(did), { method: 'DELETE', headers: { Accept: 'application/json' } })
+            .then(function (r) { return r.ok ? r.json() : null; })
+            .then(function () { savedCache = savedCache.filter(function (s) { return s.id !== did; }); ssPaint(); })
+            .catch(function () { del.disabled = false; });
+          return;
+        }
+        var nm = e.target.closest('.ssname');
+        if (nm) {
+          var nrow = nm.closest('.ssrow'); var nid = nrow && nrow.getAttribute('data-id');
+          var rec = savedCache.filter(function (s) { return s.id === nid; })[0];
+          if (rec && !restoreSnapshot(rec.filters)) ssFlash('This saved item has no restorable view.');
+        }
+      });
+    }
+    if (ssSaveBtn) {
+      ssSaveBtn.addEventListener('click', function (e) {
+        e.stopPropagation();
+        var name = (window.prompt('Name this saved search:', 'Saved search') || '').trim();
+        if (!name) return;
+        ssSaveBtn.disabled = true;
+        fetch('/api/saved-searches', {
+          method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+          body: JSON.stringify({ name: name.slice(0, 80), filters: captureSnapshot() })
+        })
+          .then(function (r) { return r.ok ? r.json() : null; })
+          .then(function (j) {
+            ssSaveBtn.disabled = false;
+            if (j && j.search) { savedCache.unshift(j.search); ssLoaded = true; ssPaint(); ssFlash('Saved.'); }
+            else ssFlash('Save failed.');
+          })
+          .catch(function () { ssSaveBtn.disabled = false; ssFlash('Save failed.'); });
+      });
+    }
+
     var toggle = function () {
       var open = wrap.classList.toggle('open');
       pill.setAttribute('aria-expanded', open ? 'true' : 'false');
+      if (open && !ssLoaded) { ssLoaded = true; ssRefresh(); }   // lazy-load on first open
     };
     pill.addEventListener('click', function (e) { e.stopPropagation(); toggle(); });
     pill.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });

← 12e1254 corner-nav: ghost/outline menu pill (transparent bg, slate 1  ·  back to Commercialrealestate  ·  auto-data-snapshot: 2026-08-26T12:08:58 (1 data files) — dat 5688317 →