chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeUtilization, findCapacityWindows } 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,
|
||||
};
|
||||
|
||||
const resource = {
|
||||
id: "res-1",
|
||||
displayName: "Jane Doe",
|
||||
chargeabilityTarget: 80,
|
||||
availability: standardAvailability,
|
||||
};
|
||||
|
||||
describe("analyzeUtilization", () => {
|
||||
it("computes chargeability for a single chargeable allocation", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [
|
||||
{
|
||||
startDate: new Date("2026-03-09"), // Monday
|
||||
endDate: new Date("2026-03-13"), // Friday
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
projectName: "Project A",
|
||||
isChargeable: true,
|
||||
},
|
||||
],
|
||||
analysisStart: new Date("2026-03-09"),
|
||||
analysisEnd: new Date("2026-03-13"),
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.resourceId).toBe("res-1");
|
||||
expect(result.currentChargeability).toBe(100);
|
||||
expect(result.chargeabilityGap).toBe(-20); // target 80, actual 100
|
||||
expect(result.overallocatedDays).toHaveLength(0);
|
||||
expect(result.underutilizedDays).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects overallocated days", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 5,
|
||||
status: "CONFIRMED",
|
||||
projectName: "A",
|
||||
isChargeable: true,
|
||||
},
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 5,
|
||||
status: "CONFIRMED",
|
||||
projectName: "B",
|
||||
isChargeable: true,
|
||||
},
|
||||
],
|
||||
analysisStart: new Date("2026-03-09"),
|
||||
analysisEnd: new Date("2026-03-09"),
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.overallocatedDays.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("detects underutilized days (below 50%)", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 2,
|
||||
status: "CONFIRMED",
|
||||
projectName: "A",
|
||||
isChargeable: true,
|
||||
},
|
||||
],
|
||||
analysisStart: new Date("2026-03-09"),
|
||||
analysisEnd: new Date("2026-03-09"),
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.underutilizedDays).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores allocations with inactive status", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CANCELLED",
|
||||
projectName: "Cancelled",
|
||||
isChargeable: true,
|
||||
},
|
||||
],
|
||||
analysisStart: new Date("2026-03-09"),
|
||||
analysisEnd: new Date("2026-03-13"),
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.currentChargeability).toBe(0);
|
||||
expect(result.allocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("separates chargeable from non-chargeable hours", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
projectName: "Chargeable",
|
||||
isChargeable: true,
|
||||
},
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
projectName: "Internal",
|
||||
isChargeable: false,
|
||||
},
|
||||
],
|
||||
analysisStart: new Date("2026-03-09"),
|
||||
analysisEnd: new Date("2026-03-09"),
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.currentChargeability).toBe(50);
|
||||
expect(result.overallocatedDays).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips weekends", () => {
|
||||
const input: CapacityAnalysisInput = {
|
||||
resource,
|
||||
allocations: [],
|
||||
analysisStart: new Date("2026-03-07"), // Saturday
|
||||
analysisEnd: new Date("2026-03-08"), // Sunday
|
||||
};
|
||||
|
||||
const result = analyzeUtilization(input);
|
||||
expect(result.currentChargeability).toBe(0);
|
||||
expect(result.overallocatedDays).toHaveLength(0);
|
||||
expect(result.underutilizedDays).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCapacityWindows", () => {
|
||||
const simpleResource = {
|
||||
id: "res-1",
|
||||
displayName: "Jane Doe",
|
||||
availability: standardAvailability,
|
||||
};
|
||||
|
||||
it("finds a full-week window with no allocations", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[],
|
||||
new Date("2026-03-09"), // Monday
|
||||
new Date("2026-03-13"), // Friday
|
||||
);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]!.availableDays).toBe(5);
|
||||
expect(windows[0]!.availableHoursPerDay).toBe(8);
|
||||
expect(windows[0]!.totalAvailableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("reduces available hours when partially allocated", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-13"),
|
||||
);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]!.availableHoursPerDay).toBe(4);
|
||||
expect(windows[0]!.totalAvailableHours).toBe(20);
|
||||
});
|
||||
|
||||
it("returns no windows when fully allocated", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-13"),
|
||||
);
|
||||
expect(windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores cancelled allocations", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CANCELLED",
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-13"),
|
||||
);
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]!.availableDays).toBe(5);
|
||||
});
|
||||
|
||||
it("splits windows around gaps", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[
|
||||
{
|
||||
startDate: new Date("2026-03-11"), // Wed only fully booked
|
||||
endDate: new Date("2026-03-11"),
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"), // Mon
|
||||
new Date("2026-03-13"), // Fri
|
||||
);
|
||||
// Two windows: Mon-Tue, Thu-Fri
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0]!.availableDays).toBe(2);
|
||||
expect(windows[1]!.availableDays).toBe(2);
|
||||
});
|
||||
|
||||
it("respects minAvailableHoursPerDay threshold", () => {
|
||||
const windows = findCapacityWindows(
|
||||
simpleResource,
|
||||
[
|
||||
{
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 6,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-09"),
|
||||
4, // need at least 4 hours free
|
||||
);
|
||||
// Only 2 hours free (8-6), threshold is 4 → no window
|
||||
expect(windows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeAvailabilityScore,
|
||||
computeCostScore,
|
||||
computeSkillScore,
|
||||
computeUtilizationScore,
|
||||
rankResources,
|
||||
} from "../skill-matcher.js";
|
||||
|
||||
const mockSkills = [
|
||||
{ skill: "TypeScript", proficiency: 5 as const, yearsExperience: 5 },
|
||||
{ skill: "React", proficiency: 4 as const, yearsExperience: 3 },
|
||||
{ skill: "Node.js", proficiency: 3 as const, yearsExperience: 2 },
|
||||
];
|
||||
|
||||
describe("computeSkillScore", () => {
|
||||
it("returns 100 for no required skills", () => {
|
||||
const result = computeSkillScore(mockSkills, []);
|
||||
expect(result.score).toBe(100);
|
||||
});
|
||||
|
||||
it("scores correctly for full match with max proficiency", () => {
|
||||
const result = computeSkillScore(mockSkills, ["TypeScript"]);
|
||||
// TypeScript proficiency 5/5 = 1.0, required portion = 70%, preferred = 30% (none)
|
||||
expect(result.score).toBeGreaterThan(90);
|
||||
expect(result.matchedRequired).toContain("TypeScript");
|
||||
expect(result.missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("scores lower for missing required skills", () => {
|
||||
const result = computeSkillScore(mockSkills, ["Python"]);
|
||||
expect(result.score).toBeLessThan(50);
|
||||
expect(result.missing).toContain("Python");
|
||||
});
|
||||
|
||||
it("matches skills case-insensitively", () => {
|
||||
const result = computeSkillScore(mockSkills, ["typescript"]);
|
||||
expect(result.matchedRequired).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accounts for preferred skills: matched preferred yields high score", () => {
|
||||
const withPref = computeSkillScore(mockSkills, ["TypeScript"], ["React"]);
|
||||
const withoutPref = computeSkillScore(mockSkills, ["TypeScript"], []);
|
||||
// Both should score high; preferred matching is reflected in breakdown
|
||||
expect(withPref.score).toBeGreaterThanOrEqual(80);
|
||||
expect(withoutPref.score).toBe(100);
|
||||
expect(withPref.matchedPreferred).toContain("React");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAvailabilityScore", () => {
|
||||
it("returns 100 for no conflicts", () => {
|
||||
expect(computeAvailabilityScore(false)).toBe(100);
|
||||
});
|
||||
|
||||
it("reduces score by 10 per conflict day", () => {
|
||||
expect(computeAvailabilityScore(true, 5)).toBe(50);
|
||||
});
|
||||
|
||||
it("floors at 0", () => {
|
||||
expect(computeAvailabilityScore(true, 15)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCostScore", () => {
|
||||
it("returns 50 for no budget defined", () => {
|
||||
expect(computeCostScore(10000)).toBe(50);
|
||||
});
|
||||
|
||||
it("returns 100 when resource cost is at budget", () => {
|
||||
expect(computeCostScore(10000, 10000)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 100 when resource cost is below budget", () => {
|
||||
expect(computeCostScore(8000, 10000)).toBe(100);
|
||||
});
|
||||
|
||||
it("penalizes resources above budget", () => {
|
||||
const score = computeCostScore(15000, 10000); // 1.5x budget
|
||||
expect(score).toBeLessThan(100);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeUtilizationScore", () => {
|
||||
it("returns 100 for well below target (20+ gap)", () => {
|
||||
expect(computeUtilizationScore(50, 80)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 0 for severely overallocated", () => {
|
||||
expect(computeUtilizationScore(110, 80)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankResources", () => {
|
||||
const resources = [
|
||||
{
|
||||
id: "r1",
|
||||
displayName: "Alice",
|
||||
eid: "EMP-001",
|
||||
skills: mockSkills,
|
||||
lcrCents: 8000,
|
||||
chargeabilityTarget: 80,
|
||||
currentUtilizationPercent: 50,
|
||||
hasAvailabilityConflicts: false,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
displayName: "Bob",
|
||||
eid: "EMP-002",
|
||||
skills: [{ skill: "Python", proficiency: 4 as const }],
|
||||
lcrCents: 12000,
|
||||
chargeabilityTarget: 80,
|
||||
currentUtilizationPercent: 90,
|
||||
hasAvailabilityConflicts: true,
|
||||
conflictDays: ["2025-01-06"],
|
||||
},
|
||||
];
|
||||
|
||||
it("returns suggestions sorted by score descending", () => {
|
||||
const results = rankResources({
|
||||
requiredSkills: ["TypeScript"],
|
||||
resources,
|
||||
});
|
||||
expect(results[0]?.score).toBeGreaterThanOrEqual(results[1]?.score ?? 0);
|
||||
});
|
||||
|
||||
it("Alice ranks higher than Bob for TypeScript requirement", () => {
|
||||
const results = rankResources({
|
||||
requiredSkills: ["TypeScript"],
|
||||
resources,
|
||||
});
|
||||
expect(results[0]?.resourceId).toBe("r1");
|
||||
});
|
||||
|
||||
it("returns all resources", () => {
|
||||
const results = rankResources({ requiredSkills: [], resources });
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeValueScore } from "../value-scorer.js";
|
||||
import type { ValueScoreWeights } from "../value-scorer.js";
|
||||
|
||||
const DEFAULT_WEIGHTS: ValueScoreWeights = {
|
||||
skillDepth: 0.30,
|
||||
skillBreadth: 0.15,
|
||||
costEfficiency: 0.25,
|
||||
chargeability: 0.15,
|
||||
experience: 0.15,
|
||||
};
|
||||
|
||||
describe("computeValueScore", () => {
|
||||
it("returns all zeros for a resource with no skills", () => {
|
||||
const result = computeValueScore(
|
||||
{
|
||||
skills: [],
|
||||
lcrCents: 8000,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80,
|
||||
maxLcrCents: 15000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(result.skillDepth).toBe(0);
|
||||
expect(result.skillBreadth).toBe(0);
|
||||
expect(result.experience).toBe(0);
|
||||
// chargeability should be 100 (right on target)
|
||||
expect(result.chargeability).toBe(100);
|
||||
// score must still be in 0-100
|
||||
expect(result.total).toBeGreaterThanOrEqual(0);
|
||||
expect(result.total).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("high proficiency + low cost = high total", () => {
|
||||
const result = computeValueScore(
|
||||
{
|
||||
skills: [
|
||||
{ skill: "Maya", proficiency: 5, yearsExperience: 10 },
|
||||
{ skill: "Houdini", proficiency: 5, yearsExperience: 8 },
|
||||
{ skill: "Nuke", proficiency: 4, yearsExperience: 5 },
|
||||
],
|
||||
lcrCents: 5000, // low cost
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80, // right on target
|
||||
maxLcrCents: 15000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
// Cheapest resource among a spread → high costEfficiency (~67%)
|
||||
// All high proficiency, on-target chargeability, experience present
|
||||
expect(result.total).toBeGreaterThanOrEqual(70);
|
||||
});
|
||||
|
||||
it("weights are correctly applied to dimensions", () => {
|
||||
// Single skill, median proficiency, mid-cost, right on target, no years
|
||||
const result = computeValueScore(
|
||||
{
|
||||
skills: [{ skill: "Test", proficiency: 3 }],
|
||||
lcrCents: 7500,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80,
|
||||
maxLcrCents: 15000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
const expectedDepth = Math.round((3 / 5) * 100); // 60
|
||||
const expectedBreadth = Math.min(100, 1 * 10); // 10
|
||||
const expectedCostEff = Math.round((1 - 7500 / 15000) * 100); // 50
|
||||
const expectedChargeability = 100; // on target
|
||||
const expectedExp = 0; // no yearsExperience
|
||||
|
||||
const expectedTotal = Math.round(
|
||||
expectedDepth * 0.30 +
|
||||
expectedBreadth * 0.15 +
|
||||
expectedCostEff * 0.25 +
|
||||
expectedChargeability * 0.15 +
|
||||
expectedExp * 0.15,
|
||||
);
|
||||
|
||||
expect(result.skillDepth).toBe(expectedDepth);
|
||||
expect(result.skillBreadth).toBe(expectedBreadth);
|
||||
expect(result.costEfficiency).toBe(expectedCostEff);
|
||||
expect(result.chargeability).toBe(expectedChargeability);
|
||||
expect(result.experience).toBe(expectedExp);
|
||||
expect(result.total).toBe(expectedTotal);
|
||||
});
|
||||
|
||||
it("guards against maxLcrCents = 0 → costEfficiency = 100", () => {
|
||||
const result = computeValueScore(
|
||||
{
|
||||
skills: [],
|
||||
lcrCents: 0,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80,
|
||||
maxLcrCents: 0, // edge case
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(result.costEfficiency).toBe(100);
|
||||
});
|
||||
|
||||
it("score is always clamped to 0-100", () => {
|
||||
const extreme = computeValueScore(
|
||||
{
|
||||
skills: Array.from({ length: 20 }, (_, i) => ({
|
||||
skill: `Skill${i}`,
|
||||
proficiency: 5 as 1 | 2 | 3 | 4 | 5,
|
||||
yearsExperience: 20,
|
||||
})),
|
||||
lcrCents: 0,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80,
|
||||
maxLcrCents: 15000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(extreme.total).toBeLessThanOrEqual(100);
|
||||
expect(extreme.total).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const worst = computeValueScore(
|
||||
{
|
||||
skills: [],
|
||||
lcrCents: 15000,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 0, // 160pp gap → chargeability = max(0, 100 - 160) = 0
|
||||
maxLcrCents: 15000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(worst.total).toBe(0);
|
||||
});
|
||||
|
||||
it("chargeability decreases as gap widens", () => {
|
||||
const onTarget = computeValueScore(
|
||||
{ skills: [], lcrCents: 5000, chargeabilityTarget: 80, currentChargeability: 80, maxLcrCents: 5000 },
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
const offTarget = computeValueScore(
|
||||
{ skills: [], lcrCents: 5000, chargeabilityTarget: 80, currentChargeability: 40, maxLcrCents: 5000 },
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(onTarget.chargeability).toBeGreaterThan(offTarget.chargeability);
|
||||
});
|
||||
|
||||
it("skill breadth caps at 100 for 10+ skills", () => {
|
||||
const manySkills = Array.from({ length: 15 }, (_, i) => ({
|
||||
skill: `Skill${i}`,
|
||||
proficiency: 3 as 1 | 2 | 3 | 4 | 5,
|
||||
}));
|
||||
const result = computeValueScore(
|
||||
{ skills: manySkills, lcrCents: 5000, chargeabilityTarget: 80, currentChargeability: 80, maxLcrCents: 10000 },
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(result.skillBreadth).toBe(100);
|
||||
});
|
||||
|
||||
it("experience caps at 100 for 10+ avg years", () => {
|
||||
const result = computeValueScore(
|
||||
{
|
||||
skills: [
|
||||
{ skill: "Maya", proficiency: 4, yearsExperience: 15 },
|
||||
{ skill: "Houdini", proficiency: 4, yearsExperience: 12 },
|
||||
],
|
||||
lcrCents: 5000,
|
||||
chargeabilityTarget: 80,
|
||||
currentChargeability: 80,
|
||||
maxLcrCents: 10000,
|
||||
},
|
||||
DEFAULT_WEIGHTS,
|
||||
);
|
||||
expect(result.experience).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./skill-matcher.js";
|
||||
export * from "./capacity-analyzer.js";
|
||||
export * from "./value-scorer.js";
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Resource, ScoreBreakdown, SkillEntry, StaffingSuggestion } from "@planarchy/shared";
|
||||
import { SCORE_WEIGHTS } from "@planarchy/shared";
|
||||
|
||||
export interface SkillMatchInput {
|
||||
requiredSkills: string[];
|
||||
preferredSkills?: string[];
|
||||
resources: (Pick<Resource, "id" | "displayName" | "eid" | "skills" | "lcrCents" | "chargeabilityTarget"> & {
|
||||
currentUtilizationPercent: number;
|
||||
hasAvailabilityConflicts: boolean;
|
||||
conflictDays?: string[];
|
||||
})[];
|
||||
budgetLcrCentsPerHour?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a skill match score (0-100) for a resource against required skills.
|
||||
* Factors in proficiency levels and coverage.
|
||||
*/
|
||||
export function computeSkillScore(
|
||||
resourceSkills: SkillEntry[],
|
||||
requiredSkills: string[],
|
||||
preferredSkills: string[] = [],
|
||||
): { score: number; matchedRequired: string[]; matchedPreferred: string[]; missing: string[] } {
|
||||
if (requiredSkills.length === 0) {
|
||||
return { score: 100, matchedRequired: [], matchedPreferred: [], missing: [] };
|
||||
}
|
||||
|
||||
const resourceSkillMap = new Map<string, SkillEntry>();
|
||||
for (const skill of resourceSkills) {
|
||||
resourceSkillMap.set(skill.skill.toLowerCase(), skill);
|
||||
}
|
||||
|
||||
const matchedRequired: string[] = [];
|
||||
const missing: string[] = [];
|
||||
|
||||
// Required skills contribute 70% of score
|
||||
let requiredScore = 0;
|
||||
for (const req of requiredSkills) {
|
||||
const match = resourceSkillMap.get(req.toLowerCase());
|
||||
if (match) {
|
||||
matchedRequired.push(req);
|
||||
// Proficiency 1-5, normalized to 0-1, weighted
|
||||
requiredScore += (match.proficiency / 5) * (70 / requiredSkills.length);
|
||||
} else {
|
||||
missing.push(req);
|
||||
}
|
||||
}
|
||||
|
||||
// Preferred skills contribute 30% as a bonus on top of required (70%).
|
||||
// If no preferred skills defined, required score is normalized to 100%.
|
||||
const matchedPreferred: string[] = [];
|
||||
let preferredScore = 0;
|
||||
if (preferredSkills.length > 0) {
|
||||
for (const pref of preferredSkills) {
|
||||
const match = resourceSkillMap.get(pref.toLowerCase());
|
||||
if (match) {
|
||||
matchedPreferred.push(pref);
|
||||
preferredScore += (match.proficiency / 5) * (30 / preferredSkills.length);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No preferred skills: normalize required score to 100-point scale
|
||||
return {
|
||||
score: Math.min(100, Math.round((requiredScore / 70) * 100)),
|
||||
matchedRequired,
|
||||
matchedPreferred: [],
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.min(100, Math.round(requiredScore + preferredScore)),
|
||||
matchedRequired,
|
||||
matchedPreferred,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes availability score (0-100) based on whether conflicts exist.
|
||||
*/
|
||||
export function computeAvailabilityScore(
|
||||
hasConflicts: boolean,
|
||||
conflictDayCount = 0,
|
||||
): number {
|
||||
if (!hasConflicts) return 100;
|
||||
// Reduce score by 10 per conflict day, minimum 0
|
||||
return Math.max(0, 100 - conflictDayCount * 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes cost score (0-100). Lower LCR = higher score.
|
||||
* Normalized against a budget target.
|
||||
*/
|
||||
export function computeCostScore(
|
||||
resourceLcrCents: number,
|
||||
budgetLcrCentsPerHour?: number,
|
||||
): number {
|
||||
if (!budgetLcrCentsPerHour || budgetLcrCentsPerHour <= 0) return 50; // Neutral
|
||||
if (resourceLcrCents <= budgetLcrCentsPerHour) return 100;
|
||||
// Above budget: score decreases linearly, 0 at 2× budget
|
||||
const ratio = resourceLcrCents / budgetLcrCentsPerHour;
|
||||
return Math.max(0, Math.round(100 - (ratio - 1) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes utilization score (0-100).
|
||||
* Prefers resources with utilization below their chargeability target
|
||||
* (i.e., capacity to absorb new work).
|
||||
*/
|
||||
export function computeUtilizationScore(
|
||||
currentUtilizationPercent: number,
|
||||
chargeabilityTarget: number,
|
||||
): number {
|
||||
const gap = chargeabilityTarget - currentUtilizationPercent;
|
||||
if (gap >= 20) return 100; // Well under target — great candidate
|
||||
if (gap >= 0) return 60 + gap * 2; // Slightly under target
|
||||
if (gap >= -20) return Math.max(0, 60 + gap * 2); // Slightly over
|
||||
return 0; // Severely overallocated
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-factor staffing scorer.
|
||||
* Returns ranked suggestions with score breakdowns.
|
||||
*
|
||||
* Score weights:
|
||||
* Skill 40%
|
||||
* Availability 30%
|
||||
* Cost 20%
|
||||
* Utilization 10%
|
||||
*/
|
||||
export function rankResources(input: SkillMatchInput): StaffingSuggestion[] {
|
||||
const { requiredSkills, preferredSkills, resources, budgetLcrCentsPerHour } = input;
|
||||
|
||||
const suggestions: StaffingSuggestion[] = resources.map((resource) => {
|
||||
const skillResult = computeSkillScore(resource.skills, requiredSkills, preferredSkills);
|
||||
const availScore = computeAvailabilityScore(
|
||||
resource.hasAvailabilityConflicts,
|
||||
resource.conflictDays?.length,
|
||||
);
|
||||
const costScore = computeCostScore(resource.lcrCents, budgetLcrCentsPerHour);
|
||||
const utilScore = computeUtilizationScore(
|
||||
resource.currentUtilizationPercent,
|
||||
resource.chargeabilityTarget,
|
||||
);
|
||||
|
||||
const total = Math.round(
|
||||
skillResult.score * SCORE_WEIGHTS.SKILL +
|
||||
availScore * SCORE_WEIGHTS.AVAILABILITY +
|
||||
costScore * SCORE_WEIGHTS.COST +
|
||||
utilScore * SCORE_WEIGHTS.UTILIZATION,
|
||||
);
|
||||
|
||||
const scoreBreakdown: ScoreBreakdown = {
|
||||
skillScore: Math.round(skillResult.score),
|
||||
availabilityScore: Math.round(availScore),
|
||||
costScore: Math.round(costScore),
|
||||
utilizationScore: Math.round(utilScore),
|
||||
total,
|
||||
};
|
||||
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
resourceName: resource.displayName,
|
||||
eid: resource.eid,
|
||||
score: total,
|
||||
scoreBreakdown,
|
||||
matchedSkills: [...skillResult.matchedRequired, ...skillResult.matchedPreferred],
|
||||
missingSkills: skillResult.missing,
|
||||
availabilityConflicts: resource.conflictDays ?? [],
|
||||
estimatedDailyCostCents: resource.lcrCents * 8,
|
||||
currentUtilization: resource.currentUtilizationPercent,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
return suggestions.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SkillEntry, ValueScoreBreakdown } from "@planarchy/shared";
|
||||
|
||||
export interface ValueScoreInput {
|
||||
skills: SkillEntry[];
|
||||
lcrCents: number;
|
||||
chargeabilityTarget: number;
|
||||
currentChargeability: number; // actual % (computed from allocations by caller)
|
||||
maxLcrCents: number; // org-wide max (for normalization)
|
||||
}
|
||||
|
||||
export interface ValueScoreWeights {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a context-free value score (price/quality ratio) for a resource.
|
||||
* All dimensions are 0-100; total is a weighted composite clamped to 0-100.
|
||||
*/
|
||||
export function computeValueScore(
|
||||
input: ValueScoreInput,
|
||||
weights: ValueScoreWeights,
|
||||
): ValueScoreBreakdown {
|
||||
const { skills, lcrCents, chargeabilityTarget, currentChargeability, maxLcrCents } = input;
|
||||
|
||||
// 1. Skill Depth: avg proficiency normalized to 100
|
||||
let skillDepth = 0;
|
||||
if (skills.length > 0) {
|
||||
const avgProficiency = skills.reduce((sum, s) => sum + s.proficiency, 0) / skills.length;
|
||||
skillDepth = Math.round((avgProficiency / 5) * 100);
|
||||
}
|
||||
|
||||
// 2. Skill Breadth: skill count capped at 10 skills → 100
|
||||
const skillBreadth = Math.min(100, skills.length * 10);
|
||||
|
||||
// 3. Cost Efficiency: cheapest = 100; if all same LCR → 0 for all
|
||||
let costEfficiency = 0;
|
||||
if (maxLcrCents === 0) {
|
||||
costEfficiency = 100;
|
||||
} else {
|
||||
costEfficiency = Math.round((1 - lcrCents / maxLcrCents) * 100);
|
||||
costEfficiency = Math.max(0, Math.min(100, costEfficiency));
|
||||
}
|
||||
|
||||
// 4. Chargeability: closer to target = higher; ±50pp gap = 0
|
||||
const chargeability = Math.max(
|
||||
0,
|
||||
100 - Math.abs(chargeabilityTarget - currentChargeability) * 2,
|
||||
);
|
||||
|
||||
// 5. Experience: avg yearsExperience capped at 10yr → 100
|
||||
let experience = 0;
|
||||
const skillsWithYears = skills.filter((s) => (s.yearsExperience ?? 0) > 0);
|
||||
if (skillsWithYears.length > 0) {
|
||||
const avgYears = skillsWithYears.reduce((sum, s) => sum + (s.yearsExperience ?? 0), 0) / skillsWithYears.length;
|
||||
experience = Math.min(100, Math.round(avgYears * 10));
|
||||
}
|
||||
|
||||
const total = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
skillDepth * weights.skillDepth +
|
||||
skillBreadth * weights.skillBreadth +
|
||||
costEfficiency * weights.costEfficiency +
|
||||
chargeability * weights.chargeability +
|
||||
experience * weights.experience,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
skillDepth,
|
||||
skillBreadth,
|
||||
costEfficiency,
|
||||
chargeability: Math.round(chargeability),
|
||||
experience,
|
||||
total,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user