[object Object]

← back to La Socrata Ingester

Viewer: saved views — named snapshots of the full view state

bcf4edb9cfd0f468bb7eab94ca4099564fe5164b · 2026-08-12 11:46:39 -0700 · Steve Abrams

- 📑 Views popover: name + save the current filters/sort/view/color/columns/density
  as a preset, apply any saved view in one click, delete with ✕. Persists to
  localStorage. Since syncURL() already serializes the whole view state to the
  querystring, a saved view is just a named snapshot of that query.
- Refactored boot into reusable hydrateFromQuery(strict) + syncControls(), shared
  by page load (persisted-pref fallback) and saved-view apply (exact snapshot).

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

Files touched

Diff

commit bcf4edb9cfd0f468bb7eab94ca4099564fe5164b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 11:46:39 2026 -0700

    Viewer: saved views — named snapshots of the full view state
    
    - 📑 Views popover: name + save the current filters/sort/view/color/columns/density
      as a preset, apply any saved view in one click, delete with ✕. Persists to
      localStorage. Since syncURL() already serializes the whole view state to the
      querystring, a saved view is just a named snapshot of that query.
    - Refactored boot into reusable hydrateFromQuery(strict) + syncControls(), shared
      by page load (persisted-pref fallback) and saved-view apply (exact snapshot).
    
    Author: Steve Abrams
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 viewer/public/index.html | 67 ++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 53 insertions(+), 14 deletions(-)

diff --git a/viewer/public/index.html b/viewer/public/index.html
index 94bffa4..f34b1c2 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -125,6 +125,12 @@
   .colspop label:hover{background:#f0f3f5}
   kbd{font:11px/1 ui-monospace,Menlo,monospace;background:#eef1f4;border:1px solid var(--line);border-bottom-width:2px;
     border-radius:4px;padding:1px 5px;color:#495057}
+  #savedpop{min-width:236px}
+  .colspop .savedrow{display:flex;justify-content:space-between;align-items:center}
+  .colspop .savedrow a.savedapply{flex:1;padding:5px 8px;cursor:pointer;border-radius:6px;color:var(--ink);text-decoration:none;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .colspop .savedrow a.savedapply:hover{background:#f0f3f5}
+  .colspop .savedrow a.saveddel{padding:5px 8px;color:var(--muted);cursor:pointer;text-decoration:none}
+  .colspop .savedrow a.saveddel:hover{color:#dc2626}
 </style>
 </head>
 <body>
@@ -146,6 +152,7 @@
       <option value="work_type">Work type</option>
     </select></span>
     <span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
+    <span class="ctl" id="savedwrap" style="position:relative"><button id="savedbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Save the current filters/sort/layout as a named view, or apply a saved one">📑 Views</button><div id="savedpop" class="colspop hidden"></div></span>
     <span class="ctl" id="colswrap" style="position:relative"><button id="colsbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Show / hide table columns">▦ Columns</button><div id="colspop" class="colspop hidden"></div></span>
     <span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
     <span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
@@ -506,27 +513,59 @@ document.addEventListener('click',e=>{ if(!e.target.closest('#colswrap')) $('#co
 document.addEventListener('keydown',e=>{
   const tag=(e.target.tagName||'').toLowerCase(), typing=tag==='input'||tag==='select'||tag==='textarea';
   if(e.key==='/' && !typing){ e.preventDefault(); $('#q').focus(); $('#q').select(); }
-  else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
+  else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); $('#savedpop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
 });
 
 function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
 function escapeAttr(s){return escapeHtml(s).replace(/'/g,'&#39;')}
 
-(function(){
-  const u=new URLSearchParams(location.search); // shareable deep-links: filters + sort + view + color
-  if(u.get('view')) state.view=u.get('view');
-  if(u.get('color')) state.colorBy=u.get('color');
+// ---- hydrate the whole view-state from a query string (shared by boot + saved views) ----
+// strict=true (saved views): absent params reset to defaults, so a snapshot restores exactly.
+// strict=false (boot): absent density/columns fall back to the user's persisted localStorage prefs.
+function hydrateFromQuery(qstr, strict){
+  const u=new URLSearchParams(qstr);
+  state.filters={};
   ['q','era','work_type','building_type','status','cd','zip','min_value','since'].forEach(k=>{ if(u.get(k)) state.filters[k]=u.get(k); });
-  if(u.get('sort')) state.sort={col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'};
-  if(u.get('cols')){ // deep-linked visible column set + order
-    const vis=u.get('cols').split(',').filter(k=>colDef(k));
-    if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); }
-  }
-  if(state.filters.q) $('#q').value=state.filters.q;
-  const d=u.get('d')||LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px');
-  document.documentElement.style.setProperty('--cols',Math.max(2,Math.round((72-d)/8))); $('#density').value=d;
-  $('#colorby').value=state.colorBy;
+  state.view=u.get('view')||'list';
+  state.colorBy=u.get('color')||'lead_score';
+  state.sort=u.get('sort')?{col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'}:{col:'lead_score',dir:'desc'};
+  if(u.get('cols')){ const vis=u.get('cols').split(',').filter(k=>colDef(k));
+    if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); } }
+  else if(strict){ colOrder=ALLCOLS.map(c=>c.key); colHidden=new Set(); }
+  state._density = u.get('d') || (strict ? '34' : LS.get('density','34'));
+}
+function syncControls(){ // push state back into the visible UI controls
+  $('#q').value = state.filters.q || '';
+  const d = state._density || '34';
+  document.documentElement.style.setProperty('--rowh', d+'px');
+  document.documentElement.style.setProperty('--cols', Math.max(2,Math.round((72-d)/8)));
+  $('#density').value = d;
+  $('#colorby').value = state.colorBy;
   $('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.v===state.view));
+}
+
+// ---- saved views: named snapshots of the full querystring ----
+const getSaved = () => LS.get('saved', []);
+function applySavedView(q){ hydrateFromQuery('?'+q, true); syncControls(); state.page=1; loadFacets(); loadRows(false); }
+function renderSavedMenu(){
+  const pop=$('#savedpop'), list=getSaved();
+  pop.innerHTML = `<div class="hd"><span>SAVED VIEWS</span></div>`+
+    `<div style="padding:2px 8px 8px;display:flex;gap:6px"><input id="savedname" placeholder="Name this view…" style="flex:1;min-width:0;padding:5px 8px"><button id="savedadd" class="loadmore" style="margin:0;padding:5px 11px">Save</button></div>`+
+    (list.length ? list.map((v,i)=>`<div class="savedrow"><a class="savedapply" data-i="${i}" title="${escapeAttr(v.q)}">${escapeHtml(v.name)}</a><a class="saveddel" data-i="${i}" title="Delete">✕</a></div>`).join('')
+      : `<div style="padding:6px 8px;color:var(--muted);font-size:12px">No saved views yet — filter, then Save.</div>`);
+  const save=()=>{ const n=$('#savedname').value.trim(); if(!n) return; syncURL(); const q=location.search.replace(/^\?/,''); const arr=getSaved(); arr.push({name:n,q}); LS.set('saved',arr); renderSavedMenu(); };
+  $('#savedadd').onclick=save;
+  $('#savedname').onkeydown=e=>{ if(e.key==='Enter') save(); };
+  pop.querySelectorAll('.savedapply').forEach(a=>a.onclick=()=>{ const v=getSaved()[+a.dataset.i]; if(v){ applySavedView(v.q); pop.classList.add('hidden'); } });
+  pop.querySelectorAll('.saveddel').forEach(a=>a.onclick=()=>{ const arr=getSaved(); arr.splice(+a.dataset.i,1); LS.set('saved',arr); renderSavedMenu(); });
+}
+$('#savedbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#savedpop');
+  if(pop.classList.contains('hidden')){ renderSavedMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
+document.addEventListener('click',e=>{ if(!e.target.closest('#savedwrap')) $('#savedpop').classList.add('hidden'); });
+
+(function(){
+  hydrateFromQuery(location.search, false); // boot: deep-link params win, else persisted prefs
+  syncControls();
   loadFacets(); loadRows(false);
 })();
 </script>

← d7a05ae Viewer: column show/hide menu, / search shortcut, density+co  ·  back to La Socrata Ingester  ·  Viewer: per-column filter inputs in the List header 48e81f3 →