chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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);
});
});