cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import type { Resource, ScoreBreakdown, SkillEntry, StaffingSuggestion } from "@capakraken/shared";
|
||
import { SCORE_WEIGHTS } from "@capakraken/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);
|
||
}
|