Files
2026-06-06 10:40:48 +08:00

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