chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
Allocation,
|
||||
CapacityWindow,
|
||||
Resource,
|
||||
UtilizationAnalysis,
|
||||
UtilizationPeriod,
|
||||
} from "@planarchy/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user