← 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
M public/services.htmlM server.js
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 →