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
+136
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,