🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user