diff --git a/packages/api/src/router/insights-anomalies.ts b/packages/api/src/router/insights-anomalies.ts new file mode 100644 index 0000000..48597d3 --- /dev/null +++ b/packages/api/src/router/insights-anomalies.ts @@ -0,0 +1,282 @@ +export interface Anomaly { + type: "budget" | "staffing" | "utilization" | "timeline"; + severity: "warning" | "critical"; + entityId: string; + entityName: string; + message: string; +} + +interface InsightDemandRecord { + headcount: number; + startDate: Date; + endDate: Date; + _count: { + assignments: number; + }; +} + +interface InsightProjectAssignmentRecord { + resourceId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + dailyCostCents: number; + status: string; +} + +interface InsightProjectRecord { + id: string; + name: string; + budgetCents: number; + startDate: Date; + endDate: Date; + demandRequirements: InsightDemandRecord[]; + assignments: InsightProjectAssignmentRecord[]; +} + +interface InsightResourceRecord { + id: string; + displayName: string; + availability: unknown; +} + +interface InsightAssignmentLoadRecord { + resourceId: string; + hoursPerDay: number; +} + +export interface InsightSnapshot { + anomalies: Anomaly[]; + summary: { + total: number; + criticalCount: number; + budget: number; + staffing: number; + timeline: number; + utilization: number; + }; +} + +export interface InsightsDbAccess { + project: { + findMany(args: Record): Promise; + }; + resource: { + findMany(args: Record): Promise; + }; + assignment: { + findMany(args: Record): Promise; + }; +} + +function countBusinessDays(start: Date, end: Date): number { + let count = 0; + const date = new Date(start); + while (date <= end) { + const dayOfWeek = date.getDay(); + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + count++; + } + date.setDate(date.getDate() + 1); + } + return count; +} + +async function loadInsightProjects(db: InsightsDbAccess["project"]) { + return db.findMany({ + where: { status: { in: ["ACTIVE", "DRAFT"] } }, + include: { + demandRequirements: { + select: { + headcount: true, + startDate: true, + endDate: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + resourceId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + dailyCostCents: true, + status: true, + }, + }, + }, + }) as Promise; +} + +async function loadInsightResources(db: InsightsDbAccess["resource"]) { + return db.findMany({ + where: { isActive: true }, + select: { + id: true, + displayName: true, + availability: true, + }, + }) as Promise; +} + +async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) { + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + return db.findMany({ + where: { + status: { in: ["ACTIVE", "CONFIRMED"] }, + startDate: { lte: periodEnd }, + endDate: { gte: periodStart }, + }, + select: { + resourceId: true, + hoursPerDay: true, + }, + }) as Promise; +} + +function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] { + return anomalies.reduce((summary, anomaly) => { + summary.total += 1; + summary[anomaly.type] += 1; + if (anomaly.severity === "critical") { + summary.criticalCount += 1; + } + return summary; + }, { + total: 0, + criticalCount: 0, + budget: 0, + staffing: 0, + timeline: 0, + utilization: 0, + }); +} + +export async function buildInsightSnapshot( + db: InsightsDbAccess, + now = new Date(), +): Promise { + const [projects, resources, activeAssignments] = await Promise.all([ + loadInsightProjects(db.project), + loadInsightResources(db.resource), + loadInsightAssignmentLoads(db.assignment, now), + ]); + + const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + const anomalies: Anomaly[] = []; + + for (const project of projects) { + if (project.budgetCents > 0) { + const totalDays = countBusinessDays(project.startDate, project.endDate); + const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate); + if (totalDays > 0 && elapsedDays > 0) { + const expectedBurnRate = elapsedDays / totalDays; + const totalCostCents = project.assignments.reduce((sum, assignment) => { + const assignmentStart = assignment.startDate < project.startDate + ? project.startDate + : assignment.startDate; + const assignmentEnd = assignment.endDate > now ? now : assignment.endDate; + if (assignmentEnd < assignmentStart) { + return sum; + } + return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd); + }, 0); + const actualBurnRate = totalCostCents / project.budgetCents; + + if (actualBurnRate > expectedBurnRate * 1.2) { + const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100); + anomalies.push({ + type: "budget", + severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning", + entityId: project.id, + entityName: project.name, + message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`, + }); + } + } + } + + const upcomingDemands = project.demandRequirements.filter( + (demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now, + ); + for (const demand of upcomingDemands) { + const unfilledCount = demand.headcount - demand._count.assignments; + const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0; + if (unfillPct > 0.3) { + anomalies.push({ + type: "staffing", + severity: unfillPct > 0.6 ? "critical" : "warning", + entityId: project.id, + entityName: project.name, + message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`, + }); + } + } + + const overrunAssignments = project.assignments.filter( + (assignment) => assignment.endDate > project.endDate + && (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"), + ); + if (overrunAssignments.length > 0) { + anomalies.push({ + type: "timeline", + severity: "warning", + entityId: project.id, + entityName: project.name, + message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`, + }); + } + } + + const resourceHoursMap = new Map(); + for (const assignment of activeAssignments) { + const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0; + resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay); + } + + for (const resource of resources) { + const availability = resource.availability as Record | null; + if (!availability) { + continue; + } + + const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5; + if (dailyAvailableHours <= 0) { + continue; + } + + const bookedHours = resourceHoursMap.get(resource.id) ?? 0; + const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100); + if (utilizationPercent > 110) { + anomalies.push({ + type: "utilization", + severity: utilizationPercent > 130 ? "critical" : "warning", + entityId: resource.id, + entityName: resource.displayName, + message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`, + }); + } else if (utilizationPercent < 40 && bookedHours > 0) { + anomalies.push({ + type: "utilization", + severity: "warning", + entityId: resource.id, + entityName: resource.displayName, + message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`, + }); + } + } + + anomalies.sort((left, right) => { + if (left.severity !== right.severity) { + return left.severity === "critical" ? -1 : 1; + } + return left.type.localeCompare(right.type); + }); + + return { + anomalies, + summary: summarizeAnomalies(anomalies), + }; +} diff --git a/packages/api/src/router/insights.ts b/packages/api/src/router/insights.ts index 0db2012..bcce7ab 100644 --- a/packages/api/src/router/insights.ts +++ b/packages/api/src/router/insights.ts @@ -2,81 +2,7 @@ import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../a import { controllerProcedure, createTRPCRouter } from "../trpc.js"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface Anomaly { - type: "budget" | "staffing" | "utilization" | "timeline"; - severity: "warning" | "critical"; - entityId: string; - entityName: string; - message: string; -} - -interface InsightDemandRecord { - headcount: number; - startDate: Date; - endDate: Date; - _count: { - assignments: number; - }; -} - -interface InsightProjectAssignmentRecord { - resourceId: string; - startDate: Date; - endDate: Date; - hoursPerDay: number; - dailyCostCents: number; - status: string; -} - -interface InsightProjectRecord { - id: string; - name: string; - budgetCents: number; - startDate: Date; - endDate: Date; - demandRequirements: InsightDemandRecord[]; - assignments: InsightProjectAssignmentRecord[]; -} - -interface InsightResourceRecord { - id: string; - displayName: string; - availability: unknown; -} - -interface InsightAssignmentLoadRecord { - resourceId: string; - hoursPerDay: number; -} - -interface InsightSnapshot { - anomalies: Anomaly[]; - summary: { - total: number; - criticalCount: number; - budget: number; - staffing: number; - timeline: number; - utilization: number; - }; -} - -interface InsightsDbAccess { - project: { - findMany(args: Record): Promise; - }; - resource: { - findMany(args: Record): Promise; - }; - assignment: { - findMany(args: Record): Promise; - }; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── +import { buildInsightSnapshot, type InsightsDbAccess } from "./insights-anomalies.js"; /** * Count business days between two dates (Mon–Fri). @@ -92,205 +18,6 @@ function countBusinessDays(start: Date, end: Date): number { return count; } -async function loadInsightProjects(db: InsightsDbAccess["project"]) { - return db.findMany({ - where: { status: { in: ["ACTIVE", "DRAFT"] } }, - include: { - demandRequirements: { - select: { - headcount: true, - startDate: true, - endDate: true, - _count: { select: { assignments: true } }, - }, - }, - assignments: { - select: { - resourceId: true, - startDate: true, - endDate: true, - hoursPerDay: true, - dailyCostCents: true, - status: true, - }, - }, - }, - }) as Promise; -} - -async function loadInsightResources(db: InsightsDbAccess["resource"]) { - return db.findMany({ - where: { isActive: true }, - select: { - id: true, - displayName: true, - availability: true, - }, - }) as Promise; -} - -async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) { - const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); - const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - return db.findMany({ - where: { - status: { in: ["ACTIVE", "CONFIRMED"] }, - startDate: { lte: periodEnd }, - endDate: { gte: periodStart }, - }, - select: { - resourceId: true, - hoursPerDay: true, - }, - }) as Promise; -} - -function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] { - return anomalies.reduce((summary, anomaly) => { - summary.total += 1; - summary[anomaly.type] += 1; - if (anomaly.severity === "critical") { - summary.criticalCount += 1; - } - return summary; - }, { - total: 0, - criticalCount: 0, - budget: 0, - staffing: 0, - timeline: 0, - utilization: 0, - }); -} - -async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise { - const [projects, resources, activeAssignments] = await Promise.all([ - loadInsightProjects(db.project), - loadInsightResources(db.resource), - loadInsightAssignmentLoads(db.assignment, now), - ]); - - const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); - const anomalies: Anomaly[] = []; - - for (const project of projects) { - if (project.budgetCents > 0) { - const totalDays = countBusinessDays(project.startDate, project.endDate); - const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate); - - if (totalDays > 0 && elapsedDays > 0) { - const expectedBurnRate = elapsedDays / totalDays; - const totalCostCents = project.assignments.reduce((sum, assignment) => { - const assignmentStart = assignment.startDate < project.startDate - ? project.startDate - : assignment.startDate; - const assignmentEnd = assignment.endDate > now ? now : assignment.endDate; - if (assignmentEnd < assignmentStart) { - return sum; - } - - return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd); - }, 0); - const actualBurnRate = totalCostCents / project.budgetCents; - - if (actualBurnRate > expectedBurnRate * 1.2) { - const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100); - anomalies.push({ - type: "budget", - severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning", - entityId: project.id, - entityName: project.name, - message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`, - }); - } - } - } - - const upcomingDemands = project.demandRequirements.filter( - (demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now, - ); - for (const demand of upcomingDemands) { - const unfilledCount = demand.headcount - demand._count.assignments; - const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0; - if (unfillPct > 0.3) { - anomalies.push({ - type: "staffing", - severity: unfillPct > 0.6 ? "critical" : "warning", - entityId: project.id, - entityName: project.name, - message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`, - }); - } - } - - const overrunAssignments = project.assignments.filter( - (assignment) => assignment.endDate > project.endDate - && (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"), - ); - if (overrunAssignments.length > 0) { - anomalies.push({ - type: "timeline", - severity: "warning", - entityId: project.id, - entityName: project.name, - message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`, - }); - } - } - - const resourceHoursMap = new Map(); - for (const assignment of activeAssignments) { - const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0; - resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay); - } - - for (const resource of resources) { - const availability = resource.availability as Record | null; - if (!availability) { - continue; - } - - const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5; - if (dailyAvailableHours <= 0) { - continue; - } - - const bookedHours = resourceHoursMap.get(resource.id) ?? 0; - const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100); - - if (utilizationPercent > 110) { - anomalies.push({ - type: "utilization", - severity: utilizationPercent > 130 ? "critical" : "warning", - entityId: resource.id, - entityName: resource.displayName, - message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`, - }); - } else if (utilizationPercent < 40 && bookedHours > 0) { - anomalies.push({ - type: "utilization", - severity: "warning", - entityId: resource.id, - entityName: resource.displayName, - message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`, - }); - } - } - - anomalies.sort((left, right) => { - if (left.severity !== right.severity) { - return left.severity === "critical" ? -1 : 1; - } - return left.type.localeCompare(right.type); - }); - - return { - anomalies, - summary: summarizeAnomalies(anomalies), - }; -} - // ─── Router ────────────────────────────────────────────────────────────────── export const insightsRouter = createTRPCRouter({