import type { Allocation, CapacityWindow, Resource, UtilizationAnalysis, UtilizationPeriod, } from "@planarchy/shared"; export interface CapacityAnalysisInput { resource: Pick; allocations: (Pick< Allocation, "startDate" | "endDate" | "hoursPerDay" | "status" > & { projectName: string; isChargeable: boolean; })[]; analysisStart: Date; analysisEnd: Date; } /** * Analyzes resource utilization over a given period. * Pure function — no DB access. */ export function analyzeUtilization(input: CapacityAnalysisInput): UtilizationAnalysis { const { resource, allocations, analysisStart, analysisEnd } = input; const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]); const activeAllocs = allocations.filter((a) => activeStatuses.has(a.status)); const periods: UtilizationPeriod[] = activeAllocs.map((a) => ({ startDate: new Date(a.startDate), endDate: new Date(a.endDate), hoursPerDay: a.hoursPerDay, projectName: a.projectName, isChargeable: a.isChargeable, })); // Compute daily utilization to find overallocated/underutilized days const overallocatedDays: string[] = []; const underutilizedDays: string[] = []; let totalWorkingDays = 0; let totalChargeableHours = 0; let totalAvailableHours = 0; const current = new Date(analysisStart); current.setHours(0, 0, 0, 0); const end = new Date(analysisEnd); end.setHours(0, 0, 0, 0); while (current <= end) { const dow = current.getDay(); const dowKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][dow] as keyof typeof resource.availability; const availableHours = resource.availability[dowKey] ?? 0; if (availableHours === 0) { current.setDate(current.getDate() + 1); continue; } totalWorkingDays++; totalAvailableHours += availableHours; let allocatedHours = 0; let chargeableHours = 0; for (const alloc of activeAllocs) { const aStart = new Date(alloc.startDate); aStart.setHours(0, 0, 0, 0); const aEnd = new Date(alloc.endDate); aEnd.setHours(0, 0, 0, 0); if (current >= aStart && current <= aEnd) { allocatedHours += alloc.hoursPerDay; if (alloc.isChargeable) { chargeableHours += alloc.hoursPerDay; } } } totalChargeableHours += chargeableHours; const dateStr = current.toISOString().split("T")[0] ?? current.toISOString(); if (allocatedHours > availableHours) { overallocatedDays.push(dateStr); } else if (allocatedHours < availableHours * 0.5) { underutilizedDays.push(dateStr); } current.setDate(current.getDate() + 1); } const currentChargeability = totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0; return { resourceId: resource.id, resourceName: resource.displayName, chargeabilityTarget: resource.chargeabilityTarget, currentChargeability, chargeabilityGap: resource.chargeabilityTarget - currentChargeability, allocations: periods, overallocatedDays, underutilizedDays, }; } /** * Finds capacity windows for a resource — periods where they have available hours. */ export function findCapacityWindows( resource: Pick, existingAllocations: Pick[], searchStart: Date, searchEnd: Date, minAvailableHoursPerDay = 4, ): CapacityWindow[] { const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]); const windows: CapacityWindow[] = []; let windowStart: Date | null = null; let windowAvailableDays = 0; let windowTotalHours = 0; let windowMinHours = Infinity; const current = new Date(searchStart); current.setHours(0, 0, 0, 0); const end = new Date(searchEnd); end.setHours(0, 0, 0, 0); function closeWindow(closeDate: Date) { if (windowStart && windowAvailableDays > 0) { const prev = new Date(closeDate); prev.setDate(prev.getDate() - 1); windows.push({ resourceId: resource.id, resourceName: resource.displayName, startDate: new Date(windowStart), endDate: prev, availableHoursPerDay: windowMinHours === Infinity ? 0 : windowMinHours, availableDays: windowAvailableDays, totalAvailableHours: windowTotalHours, }); } windowStart = null; windowAvailableDays = 0; windowTotalHours = 0; windowMinHours = Infinity; } while (current <= end) { const dow = current.getDay(); const dowKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][dow] as keyof typeof resource.availability; const maxHours = resource.availability[dowKey] ?? 0; if (maxHours === 0) { closeWindow(current); current.setDate(current.getDate() + 1); continue; } // Sum allocated hours on this day const allocatedHours = existingAllocations .filter((a) => { if (!activeStatuses.has(a.status)) return false; const aStart = new Date(a.startDate); aStart.setHours(0, 0, 0, 0); const aEnd = new Date(a.endDate); aEnd.setHours(0, 0, 0, 0); return current >= aStart && current <= aEnd; }) .reduce((sum, a) => sum + a.hoursPerDay, 0); const freeHours = Math.max(0, maxHours - allocatedHours); if (freeHours >= minAvailableHoursPerDay) { if (!windowStart) windowStart = new Date(current); windowAvailableDays++; windowTotalHours += freeHours; windowMinHours = Math.min(windowMinHours, freeHours); } else { closeWindow(current); } current.setDate(current.getDate() + 1); } closeWindow(new Date(end.getTime() + 86400000)); return windows; }