[object Object]

← back to NationalPaperHangers

find: add sort + density controls per CLAUDE.md standing rule (newest claimed/A-Z/tier/verified/creds + 240-420px card-min slider, both localStorage-persisted)

72e3f64b958e475d6259a95c17ce40cfdffbaca4 · 2026-05-06 18:10:46 -0700 · Steve

Files touched

Diff

commit 72e3f64b958e475d6259a95c17ce40cfdffbaca4
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 18:10:46 2026 -0700

    find: add sort + density controls per CLAUDE.md standing rule (newest claimed/A-Z/tier/verified/creds + 240-420px card-min slider, both localStorage-persisted)
---
 views/public/find.ejs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 96 insertions(+), 4 deletions(-)

diff --git a/views/public/find.ejs b/views/public/find.ejs
index f117d21..c2aff96 100644
--- a/views/public/find.ejs
+++ b/views/public/find.ejs
@@ -48,7 +48,24 @@
       <%= installers.length %> <%= show === 'all' ? '' : 'verified ' %>installer<%= installers.length === 1 ? '' : 's' %>
       <% if (show !== 'all') { %><span class="muted" style="font-size:13px;margin-left:8px">· directory listings hidden</span><% } %>
     </p>
-    <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+    <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
+      <!-- Sort + density per CLAUDE.md global rule: every directory grid has both -->
+      <label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)">
+        Sort
+        <select id="sort-select" style="padding:6px 10px;border:1px solid var(--border);background:var(--bg);font-size:13px;border-radius:4px">
+          <option value="default">Default</option>
+          <option value="newest_claimed">Newest claimed</option>
+          <option value="az">A → Z</option>
+          <option value="za">Z → A</option>
+          <option value="tier">Tier (Signature first)</option>
+          <option value="verified_first">Verified first</option>
+          <option value="creds">Most brand-trained creds</option>
+        </select>
+      </label>
+      <label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)">
+        Density
+        <input id="density-slider" type="range" min="240" max="420" step="20" value="300" style="width:90px" aria-label="Card density">
+      </label>
       <a href="<%= mapHref %>" class="btn btn-ghost btn-sm" style="font-size:13px" aria-label="View these results on the map">View on map</a>
       <a href="<%= toggleHref %>" class="btn btn-ghost btn-sm" style="font-size:13px">
         <%= show === 'all' ? 'Hide directory listings' : 'Show directory listings' %>
@@ -56,9 +73,22 @@
     </div>
   </div>
 
-  <div class="installer-grid">
-    <% installers.forEach(function(i){ %>
-      <a class="installer-card" href="/installer/<%= i.slug %>">
+  <div class="installer-grid" id="installerGrid" style="grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 300px), 1fr))">
+    <% installers.forEach(function(i){
+      // Tier rank for client-side sort: signature/enterprise > pro > basic > unclaimed/null
+      var _tierRank = (i.tier === 'signature' || i.tier === 'enterprise') ? 0
+                    : (i.tier === 'pro') ? 1
+                    : (i.tier === 'basic') ? 2 : 9;
+      var _verifiedRank = (i.claim_status === 'unclaimed') ? 9 : (i.verified ? 0 : 1);
+      var _claimedAt = i.claimed_at ? new Date(i.claimed_at).getTime() : 0;
+      var _credsCount = i.verified_brands_count || 0;
+    %>
+      <a class="installer-card" href="/installer/<%= i.slug %>"
+         data-name="<%= (i.business_name || '').toLowerCase() %>"
+         data-tier-rank="<%= _tierRank %>"
+         data-verified-rank="<%= _verifiedRank %>"
+         data-claimed-at="<%= _claimedAt %>"
+         data-creds="<%= _credsCount %>">
         <% var _pdImg = pickSegmentImage(i); %>
         <div class="card-image">
           <% if (_pdImg) { %>
@@ -105,3 +135,65 @@
 
 <%- include('../partials/footer') %>
 <script src="/js/find-filter.js"></script>
+<script>
+  // Sort + density per CLAUDE.md standing rule. Both persist via localStorage.
+  (function(){
+    var grid = document.getElementById('installerGrid');
+    if (!grid) return;
+    var sortSel = document.getElementById('sort-select');
+    var densSlider = document.getElementById('density-slider');
+
+    // Restore saved values
+    try {
+      var savedSort = localStorage.getItem('nph-find-sort');
+      if (savedSort && sortSel) sortSel.value = savedSort;
+      var savedDens = localStorage.getItem('nph-find-density');
+      if (savedDens && densSlider) { densSlider.value = savedDens; grid.style.setProperty('--card-min', savedDens + 'px'); }
+    } catch (e) {}
+
+    function num(el, attr) { return parseInt(el.getAttribute(attr) || '0', 10) || 0; }
+
+    function applySort() {
+      var mode = sortSel.value;
+      var cards = Array.from(grid.querySelectorAll('.installer-card'));
+      if (mode === 'default') {
+        // Server's natural order — re-sort by original DOM index, which we
+        // store the first time we touch them.
+        cards.forEach(function(c, i){ if (!c.dataset.origIdx) c.dataset.origIdx = i; });
+        cards.sort(function(a,b){ return num(a,'data-orig-idx') - num(b,'data-orig-idx'); });
+      } else if (mode === 'az') {
+        cards.sort(function(a,b){ return (a.dataset.name||'').localeCompare(b.dataset.name||''); });
+      } else if (mode === 'za') {
+        cards.sort(function(a,b){ return (b.dataset.name||'').localeCompare(a.dataset.name||''); });
+      } else if (mode === 'tier') {
+        cards.sort(function(a,b){ return num(a,'data-tier-rank') - num(b,'data-tier-rank'); });
+      } else if (mode === 'verified_first') {
+        cards.sort(function(a,b){ return num(a,'data-verified-rank') - num(b,'data-verified-rank'); });
+      } else if (mode === 'newest_claimed') {
+        cards.sort(function(a,b){ return num(b,'data-claimed-at') - num(a,'data-claimed-at'); });
+      } else if (mode === 'creds') {
+        cards.sort(function(a,b){ return num(b,'data-creds') - num(a,'data-creds'); });
+      }
+      // Stamp original index before reordering
+      cards.forEach(function(c){ if (!c.dataset.origIdx) c.dataset.origIdx = Array.from(grid.children).indexOf(c); });
+      cards.forEach(function(c){ grid.appendChild(c); });
+    }
+
+    if (sortSel) {
+      sortSel.addEventListener('change', function(){
+        try { localStorage.setItem('nph-find-sort', sortSel.value); } catch (e) {}
+        applySort();
+      });
+      // Stamp original DOM index on first paint, BEFORE first sort
+      Array.from(grid.children).forEach(function(c, i){ c.dataset.origIdx = i; });
+      if (sortSel.value !== 'default') applySort();
+    }
+
+    if (densSlider) {
+      densSlider.addEventListener('input', function(){
+        grid.style.setProperty('--card-min', densSlider.value + 'px');
+        try { localStorage.setItem('nph-find-density', densSlider.value); } catch (e) {}
+      });
+    }
+  })();
+</script>

← 7c77afb tests: e2e buyer wizard — 5 steps, conditional brand reveal,  ·  back to NationalPaperHangers  ·  tests: e2e for /find sort + density controls — 7/7, locks in 0420926 →