Files
CapaKraken/packages/staffing/src/capacity-analyzer.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

193 lines
5.8 KiB
TypeScript

import type {
Allocation,
CapacityWindow,
Resource,
UtilizationAnalysis,
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;
}
/**
* 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;
}