Predictive staffing forecast + per-contract timeline

J's ask: move the system from retrospective ranking to predictive
anticipation. Show it tracks the clock, not just the roster.

New endpoint /intelligence/staffing_forecast:
- Pulls 30-day Chicago permit window (200 permits)
- Maps work_type → role via industry heuristic
- Aggregates predicted worker demand per role
- Joins IL bench supply (workers_500k state='IL' group by role)
- Computes coverage_pct, reliable_coverage_pct
- Classifies risk: critical/tight/watch/ok
- Computes earliest staffing deadline per role
  (permit issue_date + 31d = 45d construction start - 14d window)
- Surfaces recent Chicago playbook ops for the role-specific memory

New UI 'Staffing Forecast' section ABOVE Live Contracts:
- Top card: total construction value, permit count, workers needed,
  critical/tight role count
- Per-role rows: demand vs available supply, coverage %, deadline
  with red/amber/green urgency coloring

Per-contract timeline on Live Contracts:
- estimated_construction_start, staffing_window_opens, days_to_deadline
- urgency classification: overdue/urgent/soon/scheduled
- card border colored by urgency
- timeline line explicitly shows recruiter: OVERDUE/URGENT + days count

This is the 'system already thinks about when, not just who' surface
J was asking for. CRMs store; this anticipates.
This commit is contained in:
root 2026-04-20 17:24:17 -05:00
parent 2595d48535
commit bb1b471c67
2 changed files with 207 additions and 4 deletions

View File

@ -1288,6 +1288,122 @@ tr:hover{background:#111827}
}
}
// Predictive staffing forecast — aggregate demand inferred from
// recent Chicago permits, compared to our bench supply. Answers
// "what's coming in the next 30-60 days and can we cover it?"
// — the contextual-awareness dimension beyond retrospective rank.
if (url.pathname === "/intelligence/staffing_forecast" && req.method === "POST") {
const start = Date.now();
try {
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
// Last 30 days of permits — that's our forward demand window
const thirtyDaysAgo = new Date(Date.now() - 30 * 86400e3).toISOString().slice(0, 10);
const permits: any[] = await fetch(
`${permitUrl}?$select=work_type,reported_cost,issue_date`
+ `&$where=reported_cost>100000 AND issue_date>'${thirtyDaysAgo}'`
+ `&$limit=200`
).then(r => r.json()).catch(() => []);
// Construction heuristic: permit filing → construction start
// averages ~45 days. Staffing window opens 14 days before.
const typeToRole: Record<string, string> = {
"Electrical Work": "Electrician",
"Masonry Work": "Production Worker",
"Mechanical Work": "Maintenance Tech",
"Reroofing": "Production Worker",
"Plumbing Work": "Maintenance Tech",
};
// Aggregate demand by role
const demandByRole: Record<string, { permits: number; total_cost: number; est_workers: number; earliest_need: string }> = {};
for (const p of permits) {
const role = typeToRole[p.work_type || ""] || "Production Worker";
const cost = parseFloat(p.reported_cost || 0);
const workers = Math.max(2, Math.min(Math.round(cost / 150000), 8));
const issueDate = new Date(p.issue_date);
const stagingDate = new Date(issueDate.getTime() + 31 * 86400e3); // 45d - 14d window
if (!demandByRole[role]) {
demandByRole[role] = { permits: 0, total_cost: 0, est_workers: 0,
earliest_need: stagingDate.toISOString().slice(0, 10) };
}
demandByRole[role].permits += 1;
demandByRole[role].total_cost += cost;
demandByRole[role].est_workers += workers;
const cur = new Date(demandByRole[role].earliest_need);
if (stagingDate < cur) demandByRole[role].earliest_need = stagingDate.toISOString().slice(0, 10);
}
// Bench supply in IL
const benchR = await api("POST", "/query/sql", {
sql: `SELECT role, COUNT(*) as total, `
+ `SUM(CASE WHEN CAST(availability AS DOUBLE) > 0.5 THEN 1 ELSE 0 END) as available, `
+ `SUM(CASE WHEN CAST(reliability AS DOUBLE) > 0.8 THEN 1 ELSE 0 END) as reliable `
+ `FROM workers_500k WHERE state = 'IL' `
+ `GROUP BY role`,
});
const bench: Record<string, any> = {};
for (const r of (benchR.rows || [])) bench[r.role] = r;
// Past playbook fill-speed + success signal per role
const playbookR = await api("POST", "/query/sql", {
sql: `SELECT operation, COUNT(*) as fills `
+ `FROM successful_playbooks_live `
+ `WHERE operation LIKE '%Chicago, IL%' `
+ `GROUP BY operation ORDER BY fills DESC LIMIT 20`,
});
const recentChicagoOps = playbookR.rows || [];
// Build forecast entries with risk flag
const forecast: any[] = [];
for (const [role, d] of Object.entries(demandByRole)) {
const b = bench[role] || { total: 0, available: 0, reliable: 0 };
const coverage = d.est_workers > 0 ? Math.round((b.available / d.est_workers) * 100) : 999;
const reliable_coverage = d.est_workers > 0 ? Math.round((b.reliable / d.est_workers) * 100) : 999;
let risk = "ok";
if (coverage < 100) risk = "critical";
else if (coverage < 300) risk = "tight";
else if (reliable_coverage < 200) risk = "watch";
// Days until earliest staffing deadline
const days_to_deadline = Math.round((new Date(d.earliest_need).getTime() - Date.now()) / 86400e3);
forecast.push({
role,
demand_permits: d.permits,
demand_workers: d.est_workers,
demand_total_cost: d.total_cost,
earliest_staffing_deadline: d.earliest_need,
days_to_deadline,
bench_total: b.total,
bench_available: b.available,
bench_reliable: b.reliable,
coverage_pct: Math.min(coverage, 9999),
reliable_coverage_pct: Math.min(reliable_coverage, 9999),
risk,
});
}
forecast.sort((a, b) => {
const order: Record<string, number> = { critical: 0, tight: 1, watch: 2, ok: 3 };
if (order[a.risk] !== order[b.risk]) return order[a.risk] - order[b.risk];
return a.days_to_deadline - b.days_to_deadline;
});
return ok({
generated_at: new Date().toISOString(),
window_days: 30,
permit_count: permits.length,
total_cost: permits.reduce((s, p) => s + parseFloat(p.reported_cost || 0), 0),
total_estimated_workers: forecast.reduce((s, f) => s + f.demand_workers, 0),
critical_roles: forecast.filter(f => f.risk === "critical").length,
tight_roles: forecast.filter(f => f.risk === "tight").length,
forecast,
recent_chicago_operations: recentChicagoOps,
duration_ms: Date.now() - start,
note: "Demand inferred from Chicago permit filings last 30 days. Construction starts ~45d after permit. Staffing window opens ~14d before construction. Supply = IL bench in workers_500k.",
});
} catch (e: any) {
return err(`staffing_forecast: ${e.message}`, 500);
}
}
// Intelligence: Chicago permits → assumed staffing contracts with
// Phase 19-ranked candidates and Path-2 discovered patterns. Each
// card pairs a REAL permit (live from data.cityofchicago.org) with
@ -1357,6 +1473,20 @@ tr:hover{background:#111827}
};
});
// Timeline heuristic — permits filed now → construction
// starts ~45d later → staffing window opens ~14d before
// start. days_to_deadline is negative when we're past the
// window (fill urgency is imminent).
const issueDate = new Date(p.issue_date || Date.now());
const estStart = new Date(issueDate.getTime() + 45 * 86400e3);
const stagingDate = new Date(issueDate.getTime() + 31 * 86400e3);
const daysToDeadline = Math.round((stagingDate.getTime() - Date.now()) / 86400e3);
let urgency = "scheduled";
if (daysToDeadline < 0) urgency = "overdue";
else if (daysToDeadline <= 7) urgency = "urgent";
else if (daysToDeadline <= 21) urgency = "soon";
else urgency = "scheduled";
contracts.push({
permit: {
cost,
@ -1366,6 +1496,12 @@ tr:hover{background:#111827}
community_area: p.community_area,
issue_date: (p.issue_date || "").substring(0, 10),
},
timeline: {
estimated_construction_start: estStart.toISOString().slice(0, 10),
staffing_window_opens: stagingDate.toISOString().slice(0, 10),
days_to_deadline: daysToDeadline,
urgency,
},
proposed: {
role,
count,

View File

@ -112,6 +112,14 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
<div id="main"><div class="ld">Analyzing contracts and workers...</div></div>
<div class="section" id="staffing-forecast-section">
<div class="section-header">
<span class="section-title">Staffing Forecast — Next 30 Days</span>
<span class="section-meta">Permits → predicted demand · Bench supply · Days to staffing deadline</span>
</div>
<div id="staffing-forecast"><div class="ld">Loading forecast...</div></div>
</div>
<div class="section" id="live-contracts-section">
<div class="section-header">
<span class="section-title">Live Contracts — Chicago Permits → Proposed Fills</span>
@ -155,7 +163,56 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
var A=location.origin+P;
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
var lastQuery='';
window.addEventListener('load',function(){loadDay();loadLiveContracts();loadMarket();loadLearning()});
window.addEventListener('load',function(){loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning()});
function loadStaffingForecast(){
api('/intelligence/staffing_forecast',{}).then(function(r){
var el=document.getElementById('staffing-forecast');el.textContent='';
if(!r||!r.forecast){el.textContent='Forecast unavailable.';return}
// Header summary
var hdr=document.createElement('div');hdr.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;gap:16px';
var left=document.createElement('div');
var big=document.createElement('div');big.style.cssText='font-size:22px;font-weight:700;color:#e6edf3;letter-spacing:-0.5px';
big.textContent='$'+(r.total_cost||0).toLocaleString('en-US',{maximumFractionDigits:0})+' in construction coming';
var sub=document.createElement('div');sub.style.cssText='color:#8b949e;font-size:12px;margin-top:4px';
sub.textContent=r.permit_count+' permits filed last 30 days · ~'+r.total_estimated_workers+' workers needed across roles';
left.appendChild(big);left.appendChild(sub);hdr.appendChild(left);
var right=document.createElement('div');right.style.cssText='text-align:right;font-size:11px';
if(r.critical_roles>0){
var c=document.createElement('div');c.style.cssText='color:#f85149;font-weight:700';
c.textContent=r.critical_roles+' CRITICAL role'+(r.critical_roles!==1?'s':'')+' — supply gap';right.appendChild(c);
} else if(r.tight_roles>0){
var t=document.createElement('div');t.style.cssText='color:#d29922;font-weight:700';
t.textContent=r.tight_roles+' tight role'+(r.tight_roles!==1?'s':'');right.appendChild(t);
} else {
var g=document.createElement('div');g.style.cssText='color:#3fb950;font-weight:600';g.textContent='bench covers predicted demand';right.appendChild(g);
}
var when=document.createElement('div');when.style.cssText='color:#545d68;margin-top:2px';
when.textContent='updated '+((r.duration_ms||0)/1000).toFixed(1)+'s ago';right.appendChild(when);
hdr.appendChild(right);el.appendChild(hdr);
// Per-role rows
r.forecast.forEach(function(f){
var row=document.createElement('div');
var riskColor={critical:'#f85149',tight:'#d29922',watch:'#d29922',ok:'#3fb950'}[f.risk]||'#8b949e';
row.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 14px;margin-bottom:6px;border-left:3px solid '+riskColor+';display:flex;justify-content:space-between;gap:12px;font-size:12px;align-items:center';
var l=document.createElement('div');
var n=document.createElement('div');n.style.cssText='color:#e6edf3;font-weight:600;font-size:13px';
n.textContent=f.role;
var d=document.createElement('div');d.style.cssText='color:#8b949e;font-size:11px;margin-top:2px';
d.textContent=f.demand_permits+' permit'+(f.demand_permits!==1?'s':'')+' · est '+f.demand_workers+' workers · earliest staffing deadline '+f.earliest_staffing_deadline;
l.appendChild(n);l.appendChild(d);row.appendChild(l);
var r2=document.createElement('div');r2.style.cssText='text-align:right;white-space:nowrap';
var cov=document.createElement('div');cov.style.cssText='color:'+riskColor+';font-weight:700;font-size:13px';
cov.textContent=f.bench_available.toLocaleString()+' / '+f.demand_workers+' available ('+f.coverage_pct+'%)';
var days=document.createElement('div');days.style.cssText='color:'+(f.days_to_deadline<=0?'#f85149':f.days_to_deadline<=7?'#d29922':'#8b949e')+';font-size:11px;margin-top:2px';
days.textContent=f.days_to_deadline<=0?(Math.abs(f.days_to_deadline)+'d overdue'):(f.days_to_deadline+'d to deadline');
r2.appendChild(cov);r2.appendChild(days);row.appendChild(r2);
el.appendChild(row);
});
}).catch(function(e){
document.getElementById('staffing-forecast').textContent='Forecast error: '+(e.message||e);
});
}
function api(path,body){
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json()})
@ -171,16 +228,26 @@ function loadLiveContracts(){
el.textContent='No permits returned.';return;
}
r.contracts.forEach(function(c){
var p=c.permit||{}, prop=c.proposed||{};
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
var urg=tl.urgency||'scheduled';
var borderColor={overdue:'#f85149',urgent:'#d29922',soon:'#388bfd',scheduled:'#2ea043'}[urg]||'#388bfd';
var card=document.createElement('div');card.className='insight info';
card.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:10px;border-left:3px solid #388bfd';
card.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:10px;border-left:3px solid '+borderColor;
// Header — permit
var hdr=document.createElement('div');hdr.style.cssText='display:flex;justify-content:space-between;margin-bottom:8px;gap:12px';
var left=document.createElement('div');
var title=document.createElement('div');title.style.cssText='font-weight:600;color:#e6edf3;font-size:14px';
title.textContent='$'+(p.cost||0).toLocaleString()+' · '+(p.work_type||'');
var addr=document.createElement('div');addr.style.cssText='color:#8b949e;font-size:12px;margin-top:2px';
addr.textContent=(p.address||'')+' · Chicago, IL · '+(p.issue_date||'');
addr.textContent=(p.address||'')+' · Chicago, IL · filed '+(p.issue_date||'');
// Timeline chip
if(tl.days_to_deadline!==undefined){
var tmline=document.createElement('div');tmline.style.cssText='color:'+borderColor+';font-size:11px;font-weight:600;margin-top:4px';
var urgLabel={overdue:'OVERDUE',urgent:'URGENT',soon:'SOON',scheduled:'SCHEDULED'}[urg]||'SCHEDULED';
var dd=tl.days_to_deadline;
var txt=urgLabel+' · staffing window opens '+(tl.staffing_window_opens||'')+' ('+(dd<=0?Math.abs(dd)+'d overdue':dd+'d to deadline')+') · construction est '+(tl.estimated_construction_start||'');
tmline.textContent=txt;addr.appendChild(document.createElement('br'));left.appendChild(tmline);
}
left.appendChild(title);left.appendChild(addr);
var right=document.createElement('div');right.style.cssText='color:#58a6ff;font-size:12px;font-weight:600;text-align:right;white-space:nowrap';
right.textContent=prop.count+'× '+prop.role;