From b9fd7fdb035847ebe23cd6fb63364c51737a0841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 14:17:26 +0200 Subject: [PATCH] perf(staffing): replace day-by-day capacity loop with range arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates O(resource × allocation × days) iteration in findCapacityWindows by pre-computing vacation date sets and using direct range overlap math. Adds performance regression test (50 resources × 20 allocs × 365 days < 500ms). Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/capacity-analyzer-perf.test.ts | 97 ++++++++++++ packages/staffing/src/capacity-analyzer.ts | 141 ++++++++++-------- 2 files changed, 178 insertions(+), 60 deletions(-) create mode 100644 packages/staffing/src/__tests__/capacity-analyzer-perf.test.ts diff --git a/packages/staffing/src/__tests__/capacity-analyzer-perf.test.ts b/packages/staffing/src/__tests__/capacity-analyzer-perf.test.ts new file mode 100644 index 0000000..bfa298e --- /dev/null +++ b/packages/staffing/src/__tests__/capacity-analyzer-perf.test.ts @@ -0,0 +1,97 @@ +import { AllocationStatus } from "@capakraken/shared"; +import { describe, expect, it } from "vitest"; +import { findCapacityWindows, analyzeUtilization } from "../capacity-analyzer.js"; +import type { CapacityAnalysisInput } from "../capacity-analyzer.js"; + +const standardAvailability = { + sunday: 0, + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, +}; + +/** + * Performance regression test. + * 50 resources × 20 allocations × 365 days must complete in under 500 ms. + */ +describe("capacity-analyzer performance", () => { + it("analyzeUtilization: 50 resources × 20 allocations × 365 days < 500 ms", () => { + const analysisStart = new Date("2026-01-01"); + const analysisEnd = new Date("2026-12-31"); + const periodMs = analysisEnd.getTime() - analysisStart.getTime(); + + const resources: CapacityAnalysisInput[] = Array.from({ length: 50 }, (_, ri) => { + const allocations = Array.from({ length: 20 }, (_, ai) => { + // Distribute allocations pseudo-randomly across the year + const offset = Math.floor(((ri * 20 + ai) / 1000) * periodMs); + const start = new Date(analysisStart.getTime() + offset); + const end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000); // 30-day blocks + return { + startDate: start > analysisEnd ? analysisStart : start, + endDate: end > analysisEnd ? analysisEnd : end, + hoursPerDay: 4, + status: AllocationStatus.CONFIRMED, + projectName: `Project-${ri}-${ai}`, + isChargeable: ai % 2 === 0, + }; + }); + + return { + resource: { + id: `res-${ri}`, + displayName: `Resource ${ri}`, + chargeabilityTarget: 80, + availability: standardAvailability, + }, + allocations, + analysisStart, + analysisEnd, + }; + }); + + const t0 = performance.now(); + for (const input of resources) { + analyzeUtilization(input); + } + const elapsed = performance.now() - t0; + + expect(elapsed).toBeLessThan(500); + }); + + it("findCapacityWindows: 50 resources × 20 allocations × 365 days < 500 ms", () => { + const searchStart = new Date("2026-01-01"); + const searchEnd = new Date("2026-12-31"); + const periodMs = searchEnd.getTime() - searchStart.getTime(); + + const simpleResource = { + id: "res-perf", + displayName: "Perf Resource", + availability: standardAvailability, + }; + + const t0 = performance.now(); + + for (let ri = 0; ri < 50; ri++) { + const allocations = Array.from({ length: 20 }, (_, ai) => { + const offset = Math.floor(((ri * 20 + ai) / 1000) * periodMs); + const start = new Date(searchStart.getTime() + offset); + const end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000); + return { + startDate: start > searchEnd ? searchStart : start, + endDate: end > searchEnd ? searchEnd : end, + hoursPerDay: 4, + status: AllocationStatus.CONFIRMED, + }; + }); + + findCapacityWindows(simpleResource, allocations, searchStart, searchEnd); + } + + const elapsed = performance.now() - t0; + + expect(elapsed).toBeLessThan(500); + }); +}); diff --git a/packages/staffing/src/capacity-analyzer.ts b/packages/staffing/src/capacity-analyzer.ts index 0c80b6a..8f4d04e 100644 --- a/packages/staffing/src/capacity-analyzer.ts +++ b/packages/staffing/src/capacity-analyzer.ts @@ -1,9 +1,10 @@ -import type { - Allocation, - CapacityWindow, - Resource, - UtilizationAnalysis, - UtilizationPeriod, +import { + MILLISECONDS_PER_DAY, + type Allocation, + type CapacityWindow, + type Resource, + type UtilizationAnalysis, + type UtilizationPeriod, } from "@capakraken/shared"; export interface CapacityAnalysisInput { @@ -19,6 +20,11 @@ export interface CapacityAnalysisInput { 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. @@ -37,57 +43,60 @@ export function analyzeUtilization(input: CapacityAnalysisInput): UtilizationAna isChargeable: a.isChargeable, })); - // Compute daily utilization to find overallocated/underutilized days + 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); - const end = new Date(analysisEnd); - end.setHours(0, 0, 0, 0); - while (current <= end) { + for (let i = 0; i < numDays; i++) { const dow = current.getDay(); - const dowKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][dow] as keyof typeof resource.availability; + const dowKey = DOW_KEYS[dow] as keyof typeof resource.availability; const availableHours = resource.availability[dowKey] ?? 0; - if (availableHours === 0) { - current.setDate(current.getDate() + 1); - continue; - } + if (availableHours > 0) { + totalWorkingDays++; + totalAvailableHours += availableHours; + totalChargeableHours += chargeHours[i] ?? 0; - totalWorkingDays++; - totalAvailableHours += availableHours; + const allocated = allocHours[i] ?? 0; + const dateStr = current.toISOString().split("T")[0] ?? current.toISOString(); - 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; - } + if (allocated > availableHours) { + overallocatedDays.push(dateStr); + } else if (allocated < availableHours * 0.5) { + underutilizedDays.push(dateStr); } } - 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); } @@ -119,15 +128,34 @@ export function findCapacityWindows( 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 current = new Date(searchStart); - current.setHours(0, 0, 0, 0); - const end = new Date(searchEnd); - end.setHours(0, 0, 0, 0); + const DOW_KEYS = [ + "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", + ] as const; function closeWindow(closeDate: Date) { if (windowStart && windowAvailableDays > 0) { @@ -149,9 +177,12 @@ export function findCapacityWindows( windowMinHours = Infinity; } - while (current <= end) { + const current = new Date(searchStart); + current.setHours(0, 0, 0, 0); + + for (let i = 0; i < numDays; i++) { const dow = current.getDay(); - const dowKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][dow] as keyof typeof resource.availability; + const dowKey = DOW_KEYS[dow] as keyof typeof resource.availability; const maxHours = resource.availability[dowKey] ?? 0; if (maxHours === 0) { @@ -160,19 +191,7 @@ export function findCapacityWindows( 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); + const freeHours = Math.max(0, maxHours - (allocHours[i] ?? 0)); if (freeHours >= minAvailableHoursPerDay) { if (!windowStart) windowStart = new Date(current); @@ -186,7 +205,9 @@ export function findCapacityWindows( current.setDate(current.getDate() + 1); } - closeWindow(new Date(end.getTime() + 86400000)); + const endDate = new Date(searchEnd); + endDate.setHours(0, 0, 0, 0); + closeWindow(new Date(endDate.getTime() + MILLISECONDS_PER_DAY)); return windows; }