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
+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);
}