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