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);
});
});
+192
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./skill-matcher.js";
export * from "./capacity-analyzer.js";
export * from "./value-scorer.js";
+178
View File
@@ -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);
}
+84
View File
@@ -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,
};
}