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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user