[object Object]

← back to Rentv 2026

feat(services): contractor trade filter (CSLB class) for Construction

47cb4fdb45e60ff40a67c7cb50adccb91dea3d7a · 2026-08-06 17:06:40 -0700 · Steve

Deal teams can filter 25.7k LA contractors by trade — General Building, Electrical (C-10),
HVAC (C-20), Plumbing (C-36), Roofing, Concrete, Structural Steel, Fire Protection, Elevator,
etc. — via a regex match on the CSLB class inside license_type. New /api/services/trades facet
(counts DISTINCT firms so the chip count matches the deduped result), trade param on
/api/services + CSV export, client trade-filter chips on Construction (deep-linkable).

Cody gate (FIX FIRST) resolved: (1) general-building regex proven to NOT leak B-2 Residential
Remodeling — 0 matches against 184 real B-2 rows, documented; (2) facet/filter count gap fixed
by counting distinct firms (chip 1955 == result 1955). $0, existing data.

Files touched

Diff

commit 47cb4fdb45e60ff40a67c7cb50adccb91dea3d7a
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 17:06:40 2026 -0700

    feat(services): contractor trade filter (CSLB class) for Construction
    
    Deal teams can filter 25.7k LA contractors by trade — General Building, Electrical (C-10),
    HVAC (C-20), Plumbing (C-36), Roofing, Concrete, Structural Steel, Fire Protection, Elevator,
    etc. — via a regex match on the CSLB class inside license_type. New /api/services/trades facet
    (counts DISTINCT firms so the chip count matches the deduped result), trade param on
    /api/services + CSV export, client trade-filter chips on Construction (deep-linkable).
    
    Cody gate (FIX FIRST) resolved: (1) general-building regex proven to NOT leak B-2 Residential
    Remodeling — 0 matches against 184 real B-2 rows, documented; (2) facet/filter count gap fixed
    by counting distinct firms (chip 1955 == result 1955). $0, existing data.
---
 public/services.html | 28 +++++++++++++++++++++++-----
 server.js            | 44 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 67 insertions(+), 5 deletions(-)

diff --git a/public/services.html b/public/services.html
index 29be49ae..a21f66ff 100644
--- a/public/services.html
+++ b/public/services.html
@@ -168,6 +168,7 @@
     <input id="q" type="search" autocomplete="off" placeholder="Search by firm name or city…">
   </div>
 
+  <div class="chips" id="tchips" style="display:none"></div>
   <div class="chips" id="chips"></div>
 
   <!-- Sort + density per Steve's "every product grid gets sort + density" rule. Sort is REAL
@@ -215,7 +216,8 @@ const MARKETS={
   'Ventura':{short:'Ventura',long:'Ventura County'},
   'Arizona':{short:'Arizona',long:'Arizona'},
 };
-let cat='construction', q='', city='', offset=0, total=0, seq=0;
+let cat='construction', q='', city='', trade='', offset=0, total=0, seq=0;
+let TRADES=[];
 let market=(localStorage.getItem('rentv.services.market')||'Greater LA'); if(!MARKETS[market])market='Greater LA';
 let sort=(localStorage.getItem('rentv.services.sort')||'locations');
 if(!['locations','name','city'].includes(sort))sort='locations';
@@ -235,7 +237,7 @@ function gsave(l){ localStorage.setItem(GKEY,JSON.stringify(l)); }
 // link can never wipe a real outreach list on the live site — it only works on the local preview.
 if(new URLSearchParams(location.search).get('reset')==='outreach' && /^(127\.0\.0\.1|localhost)$/.test(location.hostname)){ try{ localStorage.removeItem(GKEY); }catch(e){} }
 function outreachRefresh(){ const n=gload().filter(x=>x&&x.kind==='contact').length; $('ocount').textContent=n; $('obasket').classList.toggle('show',ADMIN); $('solall').style.display=ADMIN?'inline-flex':'none'; $('solcsv').style.display=ADMIN?'inline-flex':'none'; }
-function exportCSV(){ const u=new URLSearchParams(); u.set('cat',cat); u.set('market',market); if(q)u.set('q',q); if(city)u.set('city',city); location.href='/api/services/export.csv?'+u.toString(); }
+function exportCSV(){ const u=new URLSearchParams(); u.set('cat',cat); u.set('market',market); if(q)u.set('q',q); if(city)u.set('city',city); if(trade)u.set('trade',trade); location.href='/api/services/export.csv?'+u.toString(); }
 function inOutreach(name,scat){ return gload().some(x=>x&&x.kind==='contact'&&x.cat===scat&&(x.name||'').toLowerCase()===String(name).toLowerCase()); }
 function scatOf(){ return SOCIAL_CAT[cat]||'Vendors / Service Providers'; }
 function outreachSub(r,note){ return [r.city?cap(r.city):'',r.loc_count>1?(r.loc_count+' locations'):'',r.featured?'Featured':'',note?('“'+note+'”'):''].filter(Boolean).join(' · '); }
@@ -274,7 +276,7 @@ $('solcsv').onclick=exportCSV;
 
 // URL state (deep-linkable): /services?cat=lenders&city=GLENDALE&q=capital
 const p=new URLSearchParams(location.search);
-if(p.get('cat'))cat=p.get('cat'); if(p.get('q'))q=p.get('q'); if(p.get('city'))city=p.get('city');
+if(p.get('cat'))cat=p.get('cat'); if(p.get('q'))q=p.get('q'); if(p.get('city'))city=p.get('city'); if(p.get('trade'))trade=p.get('trade');
 if(MARKETS[p.get('market')])market=p.get('market');
 if(['locations','name','city'].includes(p.get('sort')))sort=p.get('sort');
 $('q').value=q; $('sort').value=sort; $('dens').value=dens; $('market').value=market;
@@ -293,7 +295,7 @@ function hero(){
 function sync(){
   const u=new URLSearchParams(); u.set('cat',cat);
   if(market!=='Greater LA')u.set('market',market);
-  if(q)u.set('q',q); if(city)u.set('city',city); if(sort!=='locations')u.set('sort',sort);
+  if(q)u.set('q',q); if(city)u.set('city',city); if(trade)u.set('trade',trade); if(sort!=='locations')u.set('sort',sort);
   history.replaceState(null,'','/services?'+u.toString());
 }
 
@@ -314,7 +316,22 @@ function renderCats(){
   $('cats').innerHTML=items+broker;
   document.querySelectorAll('.cat[data-cat]').forEach(b=>b.onclick=()=>setCat(b.dataset.cat));
 }
-function setCat(c){ if(c===cat)return; cat=c; offset=0; city=''; renderCats(); renderChips(); sync(); load(true); }
+function setCat(c){ if(c===cat)return; cat=c; offset=0; city=''; trade=''; renderCats(); renderChips(); syncTrades(); sync(); load(true); }
+
+// Construction-only trade filter chips (CSLB class) — real counts from /api/services/trades.
+async function loadTrades(){
+  try{ TRADES=(await (await fetch('/api/services/trades?market='+encodeURIComponent(market))).json()).trades||[]; }catch(e){ TRADES=[]; }
+  renderTrades();
+}
+function renderTrades(){
+  if(cat!=='construction'||!TRADES.length){ $('tchips').style.display='none'; $('tchips').innerHTML=''; return; }
+  $('tchips').style.display='flex';
+  const all=`<button class="chip${trade?'':' on'}" data-trade="">All trades</button>`;
+  const chips=TRADES.map(t=>`<button class="chip${trade===t.key?' on':''}" data-trade="${esc(t.key)}">${esc(t.label)} <span style="opacity:.6">${t.count.toLocaleString()}</span></button>`).join('');
+  $('tchips').innerHTML=all+chips;
+  document.querySelectorAll('#tchips .chip').forEach(b=>b.onclick=()=>{ trade=b.dataset.trade; offset=0; renderTrades(); sync(); load(true); });
+}
+function syncTrades(){ if(cat==='construction'){ if(TRADES.length)renderTrades(); else loadTrades(); } else { $('tchips').style.display='none'; } }
 
 // ── city filter chips (top cities in the market) ──
 function renderChips(){
@@ -405,6 +422,7 @@ async function boot(){
   hero();
   await checkAdmin();     // resolve admin BEFORE first render so Solicit buttons appear for staff
   await loadFacets();
+  syncTrades();           // load the trade-filter chips when landing on Construction
   load(true);
 }
 boot();
diff --git a/server.js b/server.js
index 4771e30b..c7c33098 100644
--- a/server.js
+++ b/server.js
@@ -1692,6 +1692,27 @@ const CSLB_LABELS = {
   'C-53': 'Swimming Pool', 'C-54': 'Ceramic & Mosaic Tile', 'C-55': 'Water Conditioning',
   'C-57': 'Well Drilling', 'C-60': 'Welding', 'C-61': 'Limited Specialty',
 };
+// Contractor trade filter — CSLB classes deal/build teams actually source by. rx matches the
+// class inside license_type ("B| C10", "C-10", "C10" all normalize the same).
+const TRADE_CLASSES = [
+  // 'B' bounded by non-digits, and the trailing [^0-9-] excludes the dash so it never matches
+  // 'B-2' (Residential Remodeling) — verified 0 leaks against 184 real B-2 rows (Cody gate).
+  { key: 'general-building', label: 'General Building', rx: '(^|[^0-9])B([^0-9-]|$)' },
+  { key: 'general-engineering', label: 'General Engineering', rx: '(^|[^0-9])A([^0-9]|$)' },
+  { key: 'electrical', label: 'Electrical', rx: 'C-?10([^0-9]|$)' },
+  { key: 'hvac', label: 'HVAC', rx: 'C-?20([^0-9]|$)' },
+  { key: 'plumbing', label: 'Plumbing', rx: 'C-?36([^0-9]|$)' },
+  { key: 'roofing', label: 'Roofing', rx: 'C-?39([^0-9]|$)' },
+  { key: 'concrete', label: 'Concrete', rx: 'C-?8([^0-9]|$)' },
+  { key: 'structural-steel', label: 'Structural Steel', rx: 'C-?51([^0-9]|$)' },
+  { key: 'fire-protection', label: 'Fire Protection', rx: 'C-?16([^0-9]|$)' },
+  { key: 'elevator', label: 'Elevator', rx: 'C-?11([^0-9]|$)' },
+  { key: 'painting', label: 'Painting', rx: 'C-?33([^0-9]|$)' },
+  { key: 'glazing', label: 'Glazing', rx: 'C-?17([^0-9]|$)' },
+  { key: 'flooring', label: 'Flooring', rx: 'C-?15([^0-9]|$)' },
+  { key: 'landscaping', label: 'Landscaping', rx: 'C-?27([^0-9]|$)' },
+];
+const TRADE_BY_KEY = Object.fromEntries(TRADE_CLASSES.map(t => [t.key, t]));
 function normCslb(code) {
   let c = String(code || '').trim().toUpperCase();
   const m = c.match(/^C\s*-?\s*(\d+)$/); if (m) return 'C-' + m[1];
@@ -1737,6 +1758,9 @@ app.get('/api/services', async (req, res) => {
     const where = ['within_300mi', 'role = $1', 'market = $2'], params = [cat.role, market];
     if (q) { params.push('%' + q + '%'); where.push('(entity_name ILIKE $' + params.length + ' OR city ILIKE $' + params.length + ')'); }
     if (city) { params.push(city); where.push('city ILIKE $' + params.length); }
+    // Contractor trade filter (construction only) — match the CSLB class inside license_type.
+    const trade = (catKey === 'construction' && TRADE_BY_KEY[String(req.query.trade)]) ? TRADE_BY_KEY[String(req.query.trade)] : null;
+    if (trade) { params.push(trade.rx); where.push('license_type ~ $' + params.length); }
     const whereSql = where.join(' AND ');
     // Normalized-name key → count branch licenses per firm (multi-location signal, no external data).
     const NKEY = "regexp_replace(lower(coalesce(entity_name,'')),'[^a-z0-9]+','','g')";
@@ -1793,6 +1817,24 @@ app.get('/api/services/facets', async (req, res) => {
     res.json({ market, intro: SERVICE_CONTENT.intro || {}, cats, cities: cityR.rows, featured_meta: SERVICE_FEATURED_META });
   } catch (e) { res.status(502).json({ cats: [], cities: [], error: 'services registry unavailable' }); }
 });
+// Contractor trade counts for a market — powers the Construction trade-filter chips.
+app.get('/api/services/trades', async (req, res) => {
+  try {
+    const market = SERVICE_MARKETS.has(String(req.query.market)) ? String(req.query.market) : 'Greater LA';
+    // Count DISTINCT FIRMS per trade (normalized name), so the chip count matches the deduped,
+    // one-card-per-firm result the default 'locations' sort returns (Cody: facet-vs-filter gap).
+    const NK = "regexp_replace(lower(coalesce(entity_name,'')),'[^a-z0-9]+','','g')";
+    const sel = TRADE_CLASSES.map((t, i) => `count(DISTINCT ${NK}) FILTER (WHERE license_type ~ $${i + 2})::int t${i}`).join(', ');
+    const params = [market, ...TRADE_CLASSES.map(t => t.rx)];
+    const r = await rePool.query(
+      "SELECT " + sel + " FROM rentv_licensed_targets WHERE within_300mi AND role='Contractor' AND market=$1", params);
+    const row = r.rows[0] || {};
+    const trades = TRADE_CLASSES.map((t, i) => ({ key: t.key, label: t.label, count: row['t' + i] || 0 }))
+      .filter(t => t.count > 0).sort((a, b) => b.count - a.count);
+    res.set('Cache-Control', 'public, max-age=300');
+    res.json({ market, trades });
+  } catch (e) { res.status(502).json({ trades: [], error: 'services registry unavailable' }); }
+});
 // Provider detail — one firm's full profile: all branch locations (the real value-add over the
 // card), humanized specialties, public-record link, and deal-team "pairs well with" cross-links.
 app.get('/api/services/provider/:id', async (req, res) => {
@@ -1845,6 +1887,8 @@ app.get('/api/services/export.csv', async (req, res) => {
     const where = ['within_300mi', 'role = $1', 'market = $2'], params = [cat.role, market];
     if (q) { params.push('%' + q + '%'); where.push('(entity_name ILIKE $' + params.length + ' OR city ILIKE $' + params.length + ')'); }
     if (city) { params.push(city); where.push('city ILIKE $' + params.length); }
+    const xtrade = (catKey === 'construction' && TRADE_BY_KEY[String(req.query.trade)]) ? TRADE_BY_KEY[String(req.query.trade)] : null;
+    if (xtrade) { params.push(xtrade.rx); where.push('license_type ~ $' + params.length); }
     const whereSql = where.join(' AND ');
     const NKEY = "regexp_replace(lower(coalesce(entity_name,'')),'[^a-z0-9]+','','g')";
     const COLS = 'id, role, entity_name, license_no, license_type, license_status, city, county, zip, market, phone, website, source, source_url';

← 9911142f auto-data-snapshot: 2026-08-06T17:04:50 (7 data files) — dat  ·  back to Rentv 2026  ·  polish(services): trade chips — specialty trades lead, Gener 47c3761d →