demo: profiler index — directory of every Chicago contractor
J asked for "a profiler index that shows a history of everyone." This
is a /profiler directory page (also reachable via /contractors) that
ranks every contractor who's filed a Chicago permit, by total permit
value. Rows are clickable into the full /contractor profile.
Defaults: since 2025-06-01, min permit cost $250K, top 200 contractors
by total_cost. Server pulls two Socrata GROUP BY queries (one keyed on
contact_1_name, one on contact_2_name), merges them so contractors
listed in either applicant or contractor slot appear once with combined
counts/cost. ~300ms cold.
UI: live search box, since-date selector, min-cost selector, sortable
columns (name / permits / total_cost / last_filed). Live numbers as of
this write: 200 contractors, 1,702 permits, $14.22B aggregate. Filter
"Target" returns TARGET CORPORATION + CORPORATION TARGET (name variants
from Socrata).
Also fixes J's other complaint — "no new contracts, Target is gone":
/intelligence/permit_contracts was hard-capped at $limit=6 + only
the most recent 6 over $250K, so any day with 6 fresh permits would
push older contractors (Target) off the panel entirely. Now defaults
to 24 (caller can pass body.limit up to 100), so 2-3 days of permits
stay on the panel. Added body.contractor — passes a name into the
WHERE so the staffer can pin a specific contractor to the panel
("Target Corporation" → 3 of their permits over $250K).
Server-side:
- new POST /intelligence/profiler_index — paginated contractor index
(since, min_cost, search, limit) with merged contact_1+contact_2
aggregations
- /intelligence/permit_contracts — body.limit + body.contractor
- /profiler and /contractors routes serve profiler.html
Front-end:
- new mcp-server/profiler.html — sortable table, live filter, deep
links to /contractor?name=... (prefix-aware via P, so /lakehouse
works on devop.live)
- search.html + console.html nav: added "Profiler" link
Verified end-to-end via playwright on the public URL.
This commit is contained in:
parent
31d8ef918c
commit
f6a7621b2d
@ -95,6 +95,7 @@ details .body{padding-top:10px;font-size:12px;color:#8b949e}
|
|||||||
<nav>
|
<nav>
|
||||||
<a href=".">Dashboard</a>
|
<a href=".">Dashboard</a>
|
||||||
<a href="console" class="active">Walkthrough</a>
|
<a href="console" class="active">Walkthrough</a>
|
||||||
|
<a href="profiler">Profiler</a>
|
||||||
<a href="proof">Architecture</a>
|
<a href="proof">Architecture</a>
|
||||||
<a href="spec">Spec</a>
|
<a href="spec">Spec</a>
|
||||||
<a href="onboard">Onboard</a>
|
<a href="onboard">Onboard</a>
|
||||||
|
|||||||
@ -876,6 +876,78 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profiler index — directory of every contractor that has filed
|
||||||
|
// a Chicago permit recently, ranked by permit count + total
|
||||||
|
// cost. Each name in the response links to the full /contractor
|
||||||
|
// profile page. Answers J's ask: "a profiler index that shows
|
||||||
|
// a history of everyone." Pulled live from Socrata; the
|
||||||
|
// count/cost aggregations let the staffer see who's actually
|
||||||
|
// active vs one-off LLCs.
|
||||||
|
if (url.pathname === "/intelligence/profiler_index" && req.method === "POST") {
|
||||||
|
const start = Date.now();
|
||||||
|
const b = await json();
|
||||||
|
const sinceDate = String(b.since || "2025-06-01");
|
||||||
|
const minCost = Math.max(0, Number(b.min_cost) || 100000);
|
||||||
|
const limit = Math.max(1, Math.min(500, Number(b.limit) || 200));
|
||||||
|
const search = String(b.search || "").trim();
|
||||||
|
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
|
||||||
|
// Group by contact_1_name AND by contact_2_name in two
|
||||||
|
// queries, then merge — Socrata's GROUP BY only takes one
|
||||||
|
// expression and we want both contractor slots.
|
||||||
|
const buildQuery = (col: string) => {
|
||||||
|
const where = [
|
||||||
|
`reported_cost>${minCost}`,
|
||||||
|
`issue_date>'${sinceDate.replace(/'/g, "")}'`,
|
||||||
|
`${col} IS NOT NULL`,
|
||||||
|
];
|
||||||
|
if (search) {
|
||||||
|
const s = search.replace(/'/g, "''").toUpperCase();
|
||||||
|
where.push(`upper(${col}) like '%${s}%'`);
|
||||||
|
}
|
||||||
|
return `${permitUrl}?$select=${col} AS name,count(*) as cnt,sum(reported_cost) as total_cost,max(issue_date) as last_filed&`
|
||||||
|
+ `$where=${encodeURIComponent(where.join(" AND "))}`
|
||||||
|
+ `&$group=${col}&$order=total_cost DESC&$limit=${limit}`;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const [byC1, byC2] = await Promise.all([
|
||||||
|
fetch(buildQuery("contact_1_name")).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch(buildQuery("contact_2_name")).then((r) => r.json()).catch(() => []),
|
||||||
|
]);
|
||||||
|
const merged: Record<string, { name: string; permits: number; total_cost: number; last_filed: string; roles: Set<string> }> = {};
|
||||||
|
const consume = (rows: any[], role: string) => {
|
||||||
|
for (const r of rows || []) {
|
||||||
|
const n = (r.name || "").trim();
|
||||||
|
if (!n) continue;
|
||||||
|
const cnt = parseInt(r.cnt, 10) || 0;
|
||||||
|
const cost = parseFloat(r.total_cost || "0") || 0;
|
||||||
|
const last = r.last_filed || "";
|
||||||
|
const key = n.toUpperCase();
|
||||||
|
if (!merged[key]) merged[key] = { name: n, permits: 0, total_cost: 0, last_filed: "", roles: new Set() };
|
||||||
|
merged[key].permits += cnt;
|
||||||
|
merged[key].total_cost += cost;
|
||||||
|
if (last > merged[key].last_filed) merged[key].last_filed = last;
|
||||||
|
merged[key].roles.add(role);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
consume(byC1, "applicant");
|
||||||
|
consume(byC2, "contractor");
|
||||||
|
const rows = Object.values(merged)
|
||||||
|
.map((r) => ({ ...r, roles: Array.from(r.roles) }))
|
||||||
|
.sort((a, b) => b.total_cost - a.total_cost)
|
||||||
|
.slice(0, limit);
|
||||||
|
return ok({
|
||||||
|
count: rows.length,
|
||||||
|
since: sinceDate,
|
||||||
|
min_cost: minCost,
|
||||||
|
search,
|
||||||
|
contractors: rows,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return err(`profiler_index: ${e.message}`, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Staffer roster — read by the UI dropdown so each coordinator
|
// Staffer roster — read by the UI dropdown so each coordinator
|
||||||
// can act under their own identity (per-staffer hot-swap index).
|
// can act under their own identity (per-staffer hot-swap index).
|
||||||
if (url.pathname === "/api/staffers" || url.pathname === "/staffers") {
|
if (url.pathname === "/api/staffers" || url.pathname === "/staffers") {
|
||||||
@ -1032,6 +1104,14 @@ async function main() {
|
|||||||
// OSHA national, Chicago history, ticker chart, parent link,
|
// OSHA national, Chicago history, ticker chart, parent link,
|
||||||
// federal contracts, debarment, unions, training. Click any
|
// federal contracts, debarment, unions, training. Click any
|
||||||
// contractor name in a permit Entity Brief to land here.
|
// contractor name in a permit Entity Brief to land here.
|
||||||
|
// Profiler index — directory page of everyone who's filed a
|
||||||
|
// Chicago permit (clickable directory of contractors).
|
||||||
|
if (url.pathname === "/profiler" || url.pathname === "/contractors") {
|
||||||
|
return new Response(Bun.file(import.meta.dir + "/profiler.html"), {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/contractor") {
|
if (url.pathname === "/contractor") {
|
||||||
return new Response(Bun.file(import.meta.dir + "/contractor.html"), {
|
return new Response(Bun.file(import.meta.dir + "/contractor.html"), {
|
||||||
headers: { ...cors, "Content-Type": "text/html" },
|
headers: { ...cors, "Content-Type": "text/html" },
|
||||||
@ -1351,6 +1431,7 @@ async function main() {
|
|||||||
if (url.pathname === "/intelligence/permit_contracts" && req.method === "POST") {
|
if (url.pathname === "/intelligence/permit_contracts" && req.method === "POST") {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
|
const b: any = await req.json().catch(() => ({}));
|
||||||
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
|
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
|
||||||
// Recent + substantial permits only — skip tiny ones that
|
// Recent + substantial permits only — skip tiny ones that
|
||||||
// don't imply real staffing demand.
|
// don't imply real staffing demand.
|
||||||
@ -1358,10 +1439,27 @@ async function main() {
|
|||||||
// panel on each card can populate without a second fetch.
|
// panel on each card can populate without a second fetch.
|
||||||
// Contacts identify the applicant / contractor by name —
|
// Contacts identify the applicant / contractor by name —
|
||||||
// those are the keys we pass to OSHA/ILSOS enrichment.
|
// those are the keys we pass to OSHA/ILSOS enrichment.
|
||||||
|
// Caller-controlled limit: J reported the live panel was
|
||||||
|
// dropping older permits (Target) because $limit=6 only ever
|
||||||
|
// showed today's 6 newest. Default 24 so a few days of
|
||||||
|
// permits stay on the panel; allow up to 100 via body.
|
||||||
|
const reqLimit = Math.max(1, Math.min(100, Number((b as any)?.limit) || 24));
|
||||||
|
// Optional contractor-name filter — lets the panel scope to
|
||||||
|
// a specific contact_1 or contact_2 name (e.g. "Target
|
||||||
|
// Corporation") so the user can pin a contractor to the panel
|
||||||
|
// without it scrolling past.
|
||||||
|
const cFilter = String((b as any)?.contractor || "").trim().replace(/'/g, "''");
|
||||||
|
const wherePieces: string[] = [
|
||||||
|
"reported_cost>250000",
|
||||||
|
"issue_date>'2025-06-01'",
|
||||||
|
];
|
||||||
|
if (cFilter) {
|
||||||
|
wherePieces.push(`(upper(contact_1_name)=upper('${cFilter}') OR upper(contact_2_name)=upper('${cFilter}'))`);
|
||||||
|
}
|
||||||
const permits: any[] = await fetch(
|
const permits: any[] = await fetch(
|
||||||
`${permitUrl}?$select=id,permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date,contact_1_name,contact_1_type,contact_2_name,contact_2_type&`
|
`${permitUrl}?$select=id,permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date,contact_1_name,contact_1_type,contact_2_name,contact_2_type,latitude,longitude&`
|
||||||
+ `$where=reported_cost>250000 AND issue_date>'2025-06-01'`
|
+ `$where=${encodeURIComponent(wherePieces.join(" AND "))}`
|
||||||
+ `&$order=issue_date DESC&$limit=6`
|
+ `&$order=issue_date DESC&$limit=${reqLimit}`
|
||||||
).then(r => r.json()).catch(() => []);
|
).then(r => r.json()).catch(() => []);
|
||||||
|
|
||||||
const typeToRole: Record<string, string> = {
|
const typeToRole: Record<string, string> = {
|
||||||
|
|||||||
216
mcp-server/profiler.html
Normal file
216
mcp-server/profiler.html
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Profiler Index · Staffing Co-Pilot</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{overflow-x:hidden}
|
||||||
|
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.6}
|
||||||
|
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.bar h1{font-size:14px;font-weight:600;color:#e6edf3}
|
||||||
|
.bar nav a{color:#545d68;text-decoration:none;font-size:12px;padding:6px 14px;border-radius:6px;margin-left:4px}
|
||||||
|
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||||
|
.content{max-width:1200px;margin:0 auto;padding:24px 20px 40px}
|
||||||
|
.controls{background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||||
|
.controls input,.controls select{padding:9px 12px;background:#161b22;border:1px solid #21262d;border-radius:6px;color:#e6edf3;font-size:13px;outline:none}
|
||||||
|
.controls input:focus,.controls select:focus{border-color:#388bfd}
|
||||||
|
.controls input.s{flex:1;min-width:240px}
|
||||||
|
.controls .meta{font-size:11px;color:#8b949e;margin-left:auto}
|
||||||
|
.summary{background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:14px 16px;margin-bottom:14px;font-size:12px;color:#8b949e}
|
||||||
|
.summary b{color:#e6edf3;font-weight:600}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#0d1117;border:1px solid #171d27;border-radius:10px;overflow:hidden}
|
||||||
|
th{font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1.2px;font-weight:600;text-align:left;padding:12px;background:#0a0d12;border-bottom:1px solid #171d27;cursor:pointer;user-select:none}
|
||||||
|
th:hover{color:#e6edf3}
|
||||||
|
th .arrow{font-size:9px;margin-left:4px;color:#388bfd}
|
||||||
|
td{padding:11px 12px;border-bottom:1px solid #1f2631;font-size:13px}
|
||||||
|
tr:last-child td{border-bottom:none}
|
||||||
|
tr:hover td{background:#0a0d12}
|
||||||
|
td.name a{color:#58a6ff;text-decoration:none;font-weight:600}
|
||||||
|
td.name a:hover{text-decoration:underline}
|
||||||
|
td.right{text-align:right;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
||||||
|
td.role{font-size:10px;color:#8b949e}
|
||||||
|
td.role .pill{display:inline-block;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#161b22;border:1px solid #21262d;color:#8b949e;margin-right:4px;text-transform:uppercase;letter-spacing:0.5px}
|
||||||
|
.cost-band-1{color:#3fb950}
|
||||||
|
.cost-band-2{color:#d29922}
|
||||||
|
.cost-band-3{color:#f85149}
|
||||||
|
.loading{text-align:center;padding:60px;font-size:13px;color:#3d444d}
|
||||||
|
.empty{text-align:center;padding:40px;font-size:12px;color:#545d68;font-style:italic}
|
||||||
|
.foot{margin-top:14px;font-size:10px;color:#484f58;line-height:1.6}
|
||||||
|
@media(max-width:640px){.bar{padding:0 14px}.content{padding:14px}th,td{padding:8px 6px;font-size:11px}}
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div class="bar">
|
||||||
|
<h1>Staffing Co-Pilot · Profiler Index</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="" id="back-dashboard">← Dashboard</a>
|
||||||
|
<a href="" id="back-console">Console</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="controls">
|
||||||
|
<input class="s" id="q" type="text" placeholder="Filter by contractor name (e.g., Target, Turner)" autocomplete="off">
|
||||||
|
<select id="since">
|
||||||
|
<option value="2025-06-01">Since June 2025</option>
|
||||||
|
<option value="2024-01-01">Since 2024</option>
|
||||||
|
<option value="2020-01-01">Since 2020 (deeper history)</option>
|
||||||
|
</select>
|
||||||
|
<select id="min-cost">
|
||||||
|
<option value="500000">$500K+</option>
|
||||||
|
<option value="250000" selected>$250K+</option>
|
||||||
|
<option value="100000">$100K+</option>
|
||||||
|
<option value="50000">$50K+</option>
|
||||||
|
</select>
|
||||||
|
<span class="meta" id="meta">Loading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary" id="summary" style="display:none"></div>
|
||||||
|
<div id="result"><div class="loading">Loading the directory from Chicago Socrata…</div></div>
|
||||||
|
<div class="foot">Aggregations sourced live from data.cityofchicago.org (Building Permits dataset ydr8-5enu). Contractor names appear when listed as contact_1 or contact_2 on a permit. Click any name to open the full profile — heat map, project index, history, 12 awaiting public-data sources.</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||||
|
document.getElementById('back-dashboard').href = P+'/';
|
||||||
|
document.getElementById('back-console').href = P+'/console';
|
||||||
|
|
||||||
|
var sortKey='total_cost', sortDir='desc';
|
||||||
|
var lastRows=[];
|
||||||
|
|
||||||
|
function clearChildren(el){ while(el.firstChild) el.removeChild(el.firstChild); }
|
||||||
|
function fmt$(n){
|
||||||
|
if(n>=1e9) return '$'+(n/1e9).toFixed(2)+'B';
|
||||||
|
if(n>=1e6) return '$'+(n/1e6).toFixed(1)+'M';
|
||||||
|
if(n>=1e3) return '$'+(n/1e3).toFixed(0)+'K';
|
||||||
|
return '$'+Math.round(n||0).toLocaleString();
|
||||||
|
}
|
||||||
|
function costClass(n){
|
||||||
|
if(n>=1e7) return 'cost-band-3';
|
||||||
|
if(n>=1e6) return 'cost-band-2';
|
||||||
|
return 'cost-band-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(){
|
||||||
|
var search=document.getElementById('q').value.trim();
|
||||||
|
var since=document.getElementById('since').value;
|
||||||
|
var minCost=parseInt(document.getElementById('min-cost').value,10);
|
||||||
|
document.getElementById('meta').textContent='Loading…';
|
||||||
|
var host=document.getElementById('result'); clearChildren(host);
|
||||||
|
var loading=document.createElement('div'); loading.className='loading';
|
||||||
|
loading.textContent='Aggregating from Chicago Socrata…';
|
||||||
|
host.appendChild(loading);
|
||||||
|
|
||||||
|
fetch(P+'/intelligence/profiler_index',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({since:since,min_cost:minCost,search:search,limit:200})
|
||||||
|
}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
lastRows = d.contractors||[];
|
||||||
|
document.getElementById('meta').textContent=lastRows.length+' contractors · '+(d.duration_ms||0)+'ms';
|
||||||
|
var totalCost = lastRows.reduce(function(s,r){return s+(r.total_cost||0)},0);
|
||||||
|
var totalPermits = lastRows.reduce(function(s,r){return s+(r.permits||0)},0);
|
||||||
|
var sumDiv=document.getElementById('summary');
|
||||||
|
sumDiv.style.display='block';
|
||||||
|
clearChildren(sumDiv);
|
||||||
|
var b1=document.createElement('b'); b1.textContent=lastRows.length.toLocaleString();
|
||||||
|
sumDiv.appendChild(b1);
|
||||||
|
sumDiv.appendChild(document.createTextNode(' contractors · '));
|
||||||
|
var b2=document.createElement('b'); b2.textContent=totalPermits.toLocaleString();
|
||||||
|
sumDiv.appendChild(b2);
|
||||||
|
sumDiv.appendChild(document.createTextNode(' total permits · '));
|
||||||
|
var b3=document.createElement('b'); b3.textContent=fmt$(totalCost);
|
||||||
|
sumDiv.appendChild(b3);
|
||||||
|
sumDiv.appendChild(document.createTextNode(' aggregate value · since '+(d.since||'?')+' · min permit cost '+fmt$(d.min_cost||0)));
|
||||||
|
render();
|
||||||
|
}).catch(function(e){
|
||||||
|
document.getElementById('meta').textContent='error';
|
||||||
|
var host=document.getElementById('result'); clearChildren(host);
|
||||||
|
var er=document.createElement('div'); er.className='empty'; er.style.color='#f85149';
|
||||||
|
er.textContent='Profiler index error: '+e.message;
|
||||||
|
host.appendChild(er);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
var host=document.getElementById('result');
|
||||||
|
clearChildren(host);
|
||||||
|
if(!lastRows.length){
|
||||||
|
var emp=document.createElement('div'); emp.className='empty';
|
||||||
|
emp.textContent='No contractors match the current filter.';
|
||||||
|
host.appendChild(emp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = lastRows.slice().sort(function(a,b){
|
||||||
|
var av=a[sortKey], bv=b[sortKey];
|
||||||
|
if(typeof av==='string'){ av=(av||'').toUpperCase(); bv=(bv||'').toUpperCase(); }
|
||||||
|
if(av<bv) return sortDir==='asc'?-1:1;
|
||||||
|
if(av>bv) return sortDir==='asc'?1:-1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
var t=document.createElement('table');
|
||||||
|
var thead=document.createElement('thead'); var hr=document.createElement('tr');
|
||||||
|
var cols=[
|
||||||
|
{k:'name', label:'Contractor'},
|
||||||
|
{k:'permits', label:'Permits', right:true},
|
||||||
|
{k:'total_cost', label:'Total Value', right:true},
|
||||||
|
{k:'last_filed', label:'Last Filed', right:true},
|
||||||
|
{k:'roles', label:'Listed As'},
|
||||||
|
];
|
||||||
|
cols.forEach(function(c){
|
||||||
|
var h=document.createElement('th');
|
||||||
|
h.textContent=c.label;
|
||||||
|
if(c.right) h.style.textAlign='right';
|
||||||
|
if(sortKey===c.k){
|
||||||
|
var ar=document.createElement('span'); ar.className='arrow';
|
||||||
|
ar.textContent = sortDir==='asc' ? '▲' : '▼';
|
||||||
|
h.appendChild(ar);
|
||||||
|
}
|
||||||
|
h.onclick=function(){
|
||||||
|
if(sortKey===c.k) sortDir = sortDir==='asc' ? 'desc' : 'asc';
|
||||||
|
else { sortKey=c.k; sortDir = (c.k==='name') ? 'asc' : 'desc'; }
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
hr.appendChild(h);
|
||||||
|
});
|
||||||
|
thead.appendChild(hr); t.appendChild(thead);
|
||||||
|
|
||||||
|
var tb=document.createElement('tbody');
|
||||||
|
rows.forEach(function(r){
|
||||||
|
var tr=document.createElement('tr');
|
||||||
|
var ntd=document.createElement('td'); ntd.className='name';
|
||||||
|
var a=document.createElement('a');
|
||||||
|
a.href = P+'/contractor?name='+encodeURIComponent(r.name);
|
||||||
|
a.target='_blank'; a.rel='noopener';
|
||||||
|
a.textContent = r.name;
|
||||||
|
ntd.appendChild(a);
|
||||||
|
tr.appendChild(ntd);
|
||||||
|
var ptd=document.createElement('td'); ptd.className='right';
|
||||||
|
ptd.textContent=(r.permits||0).toLocaleString();
|
||||||
|
tr.appendChild(ptd);
|
||||||
|
var ctd=document.createElement('td'); ctd.className='right '+costClass(r.total_cost||0);
|
||||||
|
ctd.textContent=fmt$(r.total_cost||0);
|
||||||
|
tr.appendChild(ctd);
|
||||||
|
var ltd=document.createElement('td'); ltd.className='right';
|
||||||
|
ltd.textContent=(r.last_filed||'').slice(0,10) || '—';
|
||||||
|
tr.appendChild(ltd);
|
||||||
|
var rtd=document.createElement('td'); rtd.className='role';
|
||||||
|
(r.roles||[]).forEach(function(role){
|
||||||
|
var pill=document.createElement('span'); pill.className='pill'; pill.textContent=role;
|
||||||
|
rtd.appendChild(pill);
|
||||||
|
});
|
||||||
|
tr.appendChild(rtd);
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
t.appendChild(tb);
|
||||||
|
host.appendChild(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sDeb;
|
||||||
|
document.getElementById('q').addEventListener('input',function(){
|
||||||
|
clearTimeout(sDeb);
|
||||||
|
sDeb=setTimeout(load,400);
|
||||||
|
});
|
||||||
|
document.getElementById('since').addEventListener('change',load);
|
||||||
|
document.getElementById('min-cost').addEventListener('change',load);
|
||||||
|
|
||||||
|
window.addEventListener('load',load);
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@ -202,6 +202,7 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="." class="active">Dashboard</a>
|
<a href="." class="active">Dashboard</a>
|
||||||
<a href="console">Walkthrough</a>
|
<a href="console">Walkthrough</a>
|
||||||
|
<a href="profiler">Profiler</a>
|
||||||
<a href="proof">Architecture</a>
|
<a href="proof">Architecture</a>
|
||||||
<a href="spec">Spec</a>
|
<a href="spec">Spec</a>
|
||||||
<a href="onboard">Onboard</a>
|
<a href="onboard">Onboard</a>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user