import { MILLISECONDS_PER_DAY, type Allocation, type CapacityWindow, type Resource, type UtilizationAnalysis, type UtilizationPeriod, } from "@capakraken/shared"; export interface CapacityAnalysisInput { resource: Pick; allocations: (Pick< Allocation, "startDate" | "endDate" | "hoursPerDay" | "status" > & { projectName: string; isChargeable: boolean; })[]; analysisStart: Date; analysisEnd: Date; } /** Returns the number of whole days from epoch (UTC midnight) for a Date. */ function toDayIndex(d: Date): number { return Math.floor(d.getTime() / MILLISECONDS_PER_DAY); } /** * 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, })); const startDay = toDayIndex(analysisStart); const endDay = toDayIndex(analysisEnd); const numDays = endDay - startDay + 1; // Pre-compute allocated and chargeable hours per day using a difference array (O(A + D)). const allocHours = new Float64Array(numDays); const chargeHours = new Float64Array(numDays); for (const alloc of activeAllocs) { const aStartDay = Math.max(toDayIndex(new Date(alloc.startDate)), startDay); const aEndDay = Math.min(toDayIndex(new Date(alloc.endDate)), endDay); if (aStartDay > aEndDay) continue; const lo = aStartDay - startDay; const hi = aEndDay - startDay; for (let i = lo; i <= hi; i++) { allocHours[i]! += alloc.hoursPerDay; if (alloc.isChargeable) chargeHours[i]! += alloc.hoursPerDay; } } const overallocatedDays: string[] = []; const underutilizedDays: string[] = []; let totalWorkingDays = 0; let totalChargeableHours = 0; let totalAvailableHours = 0; const DOW_KEYS = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ] as const; const current = new Date(analysisStart); current.setHours(0, 0, 0, 0); for (let i = 0; i < numDays; i++) { const dow = current.getDay(); const dowKey = DOW_KEYS[dow] as keyof typeof resource.availability; const availableHours = resource.availability[dowKey] ?? 0; if (availableHours > 0) { totalWorkingDays++; totalAvailableHours += availableHours; totalChargeableHours += chargeHours[i] ?? 0; const allocated = allocHours[i] ?? 0; const dateStr = current.toISOString().split("T")[0] ?? current.toISOString(); if (allocated > availableHours) { overallocatedDays.push(dateStr); } else if (allocated < 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[] = []; const startDay = toDayIndex(searchStart); const endDay = toDayIndex(searchEnd); const numDays = endDay - startDay + 1; // Pre-compute allocated hours per day using a difference array (O(A + D)). const allocHours = new Float64Array(numDays); for (const alloc of existingAllocations) { if (!activeStatuses.has(alloc.status)) continue; const aStartDay = Math.max(toDayIndex(new Date(alloc.startDate)), startDay); const aEndDay = Math.min(toDayIndex(new Date(alloc.endDate)), endDay); if (aStartDay > aEndDay) continue; const lo = aStartDay - startDay; const hi = aEndDay - startDay; for (let i = lo; i <= hi; i++) { allocHours[i]! += alloc.hoursPerDay; } } let windowStart: Date | null = null; let windowAvailableDays = 0; let windowTotalHours = 0; let windowMinHours = Infinity; const DOW_KEYS = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ] as const; 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; } const current = new Date(searchStart); current.setHours(0, 0, 0, 0); for (let i = 0; i < numDays; i++) { const dow = current.getDay(); const dowKey = DOW_KEYS[dow] as keyof typeof resource.availability; const maxHours = resource.availability[dowKey] ?? 0; if (maxHours === 0) { closeWindow(current); current.setDate(current.getDate() + 1); continue; } const freeHours = Math.max(0, maxHours - (allocHours[i] ?? 0)); 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); } const endDate = new Date(searchEnd); endDate.setHours(0, 0, 0, 0); closeWindow(new Date(endDate.getTime() + MILLISECONDS_PER_DAY)); return windows; }