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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user