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:
parent
2595d48535
commit
bb1b471c67
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user