75167d6129
- ScenarioPlanner.Baseline.shortCode: string → string | null (matches Prisma) - ScenarioPlanner.SimulationResult.chargeabilityTarget: number → number | null - Remove runtime Zod parse from scenario procedures (typed by Prisma already) - Float64Array index access: add non-null assertions for noUncheckedIndexedAccess Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
import {
|
|
MILLISECONDS_PER_DAY,
|
|
type Allocation,
|
|
type CapacityWindow,
|
|
type Resource,
|
|
type UtilizationAnalysis,
|
|
type UtilizationPeriod,
|
|
} from "@capakraken/shared";
|
|
|
|
export interface CapacityAnalysisInput {
|
|
resource: Pick<Resource, "id" | "displayName" | "chargeabilityTarget" | "availability">;
|
|
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<Resource, "id" | "displayName" | "availability">,
|
|
existingAllocations: Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
|
|
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;
|
|
}
|