278 lines
9.7 KiB
JavaScript
278 lines
9.7 KiB
JavaScript
/**
|
|
* PR-37 Delivery Planning — Capacity Planner (Part I)
|
|
*
|
|
* Calculates total effort (with complexity factors), total capacity,
|
|
* capacity utilization, risk-adjusted effort, delivery forecast dates,
|
|
* and resource recommendations when utilization exceeds 100%.
|
|
*
|
|
* @module pr-37-delivery-planning/capacity-planner
|
|
* @since PR-37
|
|
*/
|
|
|
|
/**
|
|
* Default capacity planning configuration.
|
|
*
|
|
* @param {object} [cfg]
|
|
* @param {number} [cfg.teamSize=5] — Number of team members
|
|
* @param {number} [cfg.velocity=20] — Team velocity in story points per sprint
|
|
* @param {number} [cfg.availabilityFactor=0.8] — Team availability (80% = 1 day lost/week)
|
|
* @param {object} [cfg.complexityFactors] — Story point multiplier by complexity
|
|
* @param {number} [cfg.complexityFactors.high=2.0]
|
|
* @param {number} [cfg.complexityFactors.medium=1.0]
|
|
* @param {number} [cfg.complexityFactors.low=0.5]
|
|
* @param {number} [cfg.riskBufferPercent=20] — Risk buffer as percentage of effort
|
|
* @returns {object} Normalized config
|
|
*/
|
|
export function defaultCapacityConfig(cfg = {}) {
|
|
return {
|
|
teamSize: cfg.teamSize ?? 5,
|
|
velocity: cfg.velocity ?? 20,
|
|
availabilityFactor: cfg.availabilityFactor ?? 0.8,
|
|
complexityFactors: {
|
|
high: (cfg.complexityFactors && cfg.complexityFactors.high) ?? 2.0,
|
|
medium: (cfg.complexityFactors && cfg.complexityFactors.medium) ?? 1.0,
|
|
low: (cfg.complexityFactors && cfg.complexityFactors.low) ?? 0.5,
|
|
},
|
|
riskBufferPercent: cfg.riskBufferPercent ?? 20,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a comprehensive capacity plan from sprint and release plans.
|
|
*
|
|
* @param {object} opts
|
|
* @param {object} opts.sprints — Sprint plan { sprints, metrics }
|
|
* @param {object} opts.releases — Release plan { releases }
|
|
* @param {object} [opts.config] — Capacity planning config
|
|
* @returns {object} Capacity plan
|
|
*
|
|
* @example
|
|
* ```js
|
|
* const plan = generateCapacityPlan({
|
|
* sprints: sprintPlan,
|
|
* releases: releasePlan,
|
|
* config: { teamSize: 5, velocity: 20, availabilityFactor: 0.8, riskBufferPercent: 20 },
|
|
* });
|
|
* ```
|
|
*/
|
|
export function generateCapacityPlan({ sprints, releases, config }) {
|
|
const cfg = defaultCapacityConfig(config);
|
|
const warnings = [];
|
|
|
|
// ── 1. Validate inputs ──
|
|
// Accept both { sprints: [...] } (array) and { sprints: { sprints: [...] } } (plan object)
|
|
const sprintList = (sprints && (Array.isArray(sprints) ? sprints : sprints.sprints)) || [];
|
|
const releaseList = (releases && (Array.isArray(releases) ? releases : releases.releases)) || [];
|
|
const totalSprints = sprintList.length;
|
|
|
|
if (totalSprints === 0) {
|
|
return {
|
|
totalEffort: 0,
|
|
totalCapacity: 0,
|
|
capacityUtilization: "0.0%",
|
|
sprintBySprint: [],
|
|
riskAdjustedEffort: 0,
|
|
deliveryForecast: { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" },
|
|
resourceRecommendations: ["No sprints to plan; provide a sprint plan first"],
|
|
warnings: ["No sprints available for capacity planning"],
|
|
};
|
|
}
|
|
|
|
// ── 2. Calculate total effort with complexity factors ──
|
|
// Each sprint's total story points serve as effort, adjusted by complexity
|
|
let totalRawEffort = 0;
|
|
const sprintEffortMap = new Map();
|
|
|
|
for (const sprint of sprintList) {
|
|
const rawPoints = sprint.totalStoryPoints || 0;
|
|
totalRawEffort += rawPoints;
|
|
sprintEffortMap.set(sprint.sprintNumber, rawPoints);
|
|
}
|
|
|
|
// Estimate complexity distribution (in real scenarios, stories would have complexity tags)
|
|
// Default heuristic: 20% high, 60% medium, 20% low
|
|
const highRatio = 0.2;
|
|
const mediumRatio = 0.6;
|
|
const lowRatio = 0.2;
|
|
|
|
const highEffort = totalRawEffort * highRatio * cfg.complexityFactors.high;
|
|
const mediumEffort = totalRawEffort * mediumRatio * cfg.complexityFactors.medium;
|
|
const lowEffort = totalRawEffort * lowRatio * cfg.complexityFactors.low;
|
|
|
|
const totalEffort = Math.round(highEffort + mediumEffort + lowEffort);
|
|
|
|
// ── 3. Calculate total capacity ──
|
|
const totalCapacity = Math.round(
|
|
cfg.teamSize * cfg.velocity * totalSprints * cfg.availabilityFactor
|
|
);
|
|
|
|
// ── 4. Sprint-by-sprint capacity vs allocation ──
|
|
const sprintBySprint = sprintList.map((sprint) => {
|
|
const sprintCapacity = Math.round(cfg.velocity * cfg.availabilityFactor);
|
|
const allocated = sprintEffortMap.get(sprint.sprintNumber) || 0;
|
|
const utilization = sprintCapacity > 0
|
|
? ((allocated / sprintCapacity) * 100).toFixed(1) + "%"
|
|
: "0.0%";
|
|
|
|
return {
|
|
sprintNumber: sprint.sprintNumber,
|
|
sprintGoal: sprint.sprintGoal || `Sprint ${sprint.sprintNumber}`,
|
|
capacity: sprintCapacity,
|
|
allocated,
|
|
utilization,
|
|
overCapacity: allocated > sprintCapacity,
|
|
};
|
|
});
|
|
|
|
// ── 5. Capacity utilization ──
|
|
const capacityUtilization = totalCapacity > 0
|
|
? ((totalEffort / totalCapacity) * 100).toFixed(1) + "%"
|
|
: "0.0%";
|
|
|
|
// ── 6. Risk-adjusted effort ──
|
|
const riskBuffer = totalEffort * (cfg.riskBufferPercent / 100);
|
|
const riskAdjustedEffort = Math.round(totalEffort + riskBuffer);
|
|
|
|
// ── 7. Delivery forecast ──
|
|
const deliveryForecast = computeDeliveryForecast(
|
|
sprintList, totalEffort, totalCapacity, cfg, releaseList
|
|
);
|
|
|
|
// ── 8. Resource recommendations ──
|
|
const resourceRecommendations = computeResourceRecommendations(
|
|
totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints
|
|
);
|
|
|
|
// ── 9. Warnings ──
|
|
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
|
|
if (utilizationNum > 100) {
|
|
warnings.push(`Overall capacity utilization is ${utilizationNum.toFixed(1)}% — team is over capacity`);
|
|
}
|
|
for (const ss of sprintBySprint) {
|
|
if (ss.overCapacity) {
|
|
warnings.push(`Sprint ${ss.sprintNumber} is over capacity (${ss.allocated} > ${ss.capacity} pts)`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalEffort,
|
|
totalCapacity,
|
|
capacityUtilization,
|
|
sprintBySprint,
|
|
riskAdjustedEffort,
|
|
deliveryForecast,
|
|
resourceRecommendations,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute optimistic, realistic, and pessimistic delivery dates.
|
|
*
|
|
* @param {object[]} sprintList — Sprints from sprint plan
|
|
* @param {number} totalEffort — Adjusted total effort
|
|
* @param {number} totalCapacity — Calculated total capacity
|
|
* @param {object} cfg — Capacity config
|
|
* @param {object[]} releaseList — Releases from release plan
|
|
* @returns {object} { optimistic: string, realistic: string, pessimistic: string }
|
|
*/
|
|
function computeDeliveryForecast(sprintList, totalEffort, totalCapacity, cfg, releaseList) {
|
|
const sprintCount = sprintList.length;
|
|
if (sprintCount === 0) {
|
|
return { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" };
|
|
}
|
|
|
|
const weeksPerSprint = 2; // Standard 2-week sprints
|
|
|
|
// Optimistic: current velocity continues, no blockers
|
|
const optimisticWeeks = Math.ceil(
|
|
(totalEffort / Math.max(1, cfg.velocity)) * weeksPerSprint
|
|
);
|
|
|
|
// Realistic: with availability factor
|
|
const realisticWeeks = Math.ceil(
|
|
(totalEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) * weeksPerSprint
|
|
);
|
|
|
|
// Pessimistic: risk-adjusted effort / lower availability
|
|
const pessimisticEffort = totalEffort * 1.3; // 30% buffer for worst case
|
|
const pessimisticWeeks = Math.ceil(
|
|
(pessimisticEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor * 0.85)) * weeksPerSprint
|
|
);
|
|
|
|
// Reference: last major release determines baseline
|
|
const lastRelease = releaseList.length > 0
|
|
? releaseList[releaseList.length - 1]
|
|
: null;
|
|
const releaseLabel = lastRelease
|
|
? `after ${lastRelease.name} start`
|
|
: "from project start";
|
|
|
|
return {
|
|
optimistic: `Week ${optimisticWeeks} ${releaseLabel}`,
|
|
realistic: `Week ${realisticWeeks} ${releaseLabel}`,
|
|
pessimistic: `Week ${pessimisticWeeks} ${releaseLabel}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute resource recommendations when capacity is exceeded.
|
|
*
|
|
* @param {number} totalEffort — Adjusted total effort
|
|
* @param {number} totalCapacity — Total capacity
|
|
* @param {number} riskAdjustedEffort — Effort with risk buffer
|
|
* @param {object} cfg — Capacity config
|
|
* @param {number} totalSprints — Number of sprints
|
|
* @returns {string[]} Recommendations
|
|
*/
|
|
function computeResourceRecommendations(totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints) {
|
|
const recommendations = [];
|
|
|
|
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
|
|
|
|
if (utilizationNum <= 100) {
|
|
recommendations.push("Team capacity is sufficient for planned work");
|
|
recommendations.push(`Keep team size at ${cfg.teamSize} with current velocity of ${cfg.velocity} pts/sprint`);
|
|
return recommendations;
|
|
}
|
|
|
|
// Over-capacity: suggest fixes
|
|
const excessPct = utilizationNum - 100;
|
|
const requiredSprintIncrease = Math.ceil(
|
|
(riskAdjustedEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) - totalSprints
|
|
);
|
|
|
|
const requiredTeamSize = Math.ceil(
|
|
riskAdjustedEffort / (totalSprints * cfg.velocity * cfg.availabilityFactor)
|
|
);
|
|
|
|
if (requiredSprintIncrease > 0) {
|
|
recommendations.push(
|
|
`Extend by ${requiredSprintIncrease} sprint(s) to accommodate ${riskAdjustedEffort} risk-adjusted points`
|
|
);
|
|
}
|
|
|
|
if (requiredTeamSize > cfg.teamSize) {
|
|
recommendations.push(
|
|
`Increase team size from ${cfg.teamSize} to ${requiredTeamSize} to stay within ${totalSprints} sprints`
|
|
);
|
|
}
|
|
|
|
if (excessPct > 50) {
|
|
recommendations.push(
|
|
"Consider reducing scope or deferring non-critical features to later releases"
|
|
);
|
|
recommendations.push(
|
|
`Explore outsourcing ${Math.ceil(totalEffort * 0.2)} story points to external teams`
|
|
);
|
|
}
|
|
|
|
recommendations.push(
|
|
`Reduce complexity factor application by simplifying high-complexity stories`
|
|
);
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
export default { generateCapacityPlan, defaultCapacityConfig };
|