@@ -82,6 +82,10 @@ type ExtrapolatedBreakdown struct {
8282 // Grand totals
8383 TotalCost float64 `json:"total_cost"`
8484 TotalHours float64 `json:"total_hours"`
85+
86+ // R2R cost savings calculation
87+ UniqueNonBotUsers int `json:"unique_non_bot_users"` // Count of unique non-bot users (authors + participants)
88+ R2RSavings float64 `json:"r2r_savings"` // Annual savings if R2R cuts PR time to 40 minutes
8589}
8690
8791// ExtrapolateFromSamples calculates extrapolated cost estimates from a sample
@@ -113,6 +117,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
113117
114118 // Track unique PR authors (excluding bots)
115119 uniqueAuthors := make (map [string ]bool )
120+ // Track unique non-bot users (authors + participants)
121+ uniqueNonBotUsers := make (map [string ]bool )
116122
117123 // Track bot vs human PR metrics
118124 var humanPRCount , botPRCount int
@@ -140,6 +146,7 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
140146 // Track unique PR authors only (excluding bots)
141147 if ! breakdown .AuthorBot {
142148 uniqueAuthors [breakdown .PRAuthor ] = true
149+ uniqueNonBotUsers [breakdown .PRAuthor ] = true
143150 humanPRCount ++
144151 sumHumanPRDuration += breakdown .PRDuration
145152 } else {
@@ -150,6 +157,12 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
150157 sumBotModifiedLines += breakdown .Author .ModifiedLines
151158 }
152159
160+ // Track unique participants (excluding bots)
161+ for _ , p := range breakdown .Participants {
162+ // Participants from the Breakdown struct are already filtered to exclude bots
163+ uniqueNonBotUsers [p .Actor ] = true
164+ }
165+
153166 // Accumulate PR duration (all PRs)
154167 sumPRDuration += breakdown .PRDuration
155168
@@ -322,6 +335,62 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
322335 extHumanPRs := int (float64 (humanPRCount ) / samples * multiplier )
323336 extBotPRs := int (float64 (botPRCount ) / samples * multiplier )
324337
338+ // Calculate R2R savings
339+ // Formula: baseline annual waste - (re-modeled waste with 40min PRs) - (R2R subscription cost)
340+ // Baseline annual waste: preventable cost extrapolated to 52 weeks
341+ uniqueUserCount := len (uniqueNonBotUsers )
342+ baselineAnnualWaste := (extCodeChurnCost + extDeliveryDelayCost + extAutomatedUpdatesCost + extPRTrackingCost ) * (52.0 / (float64 (daysInPeriod ) / 7.0 ))
343+
344+ // Re-model with 40-minute PR merge times
345+ // We need to recalculate delivery delay and future costs assuming all PRs take 40 minutes (2/3 hour)
346+ const targetMergeTimeHours = 40.0 / 60.0 // 40 minutes in hours
347+
348+ // Recalculate delivery delay cost with 40-minute PRs
349+ // Delivery delay formula: hourlyRate × deliveryDelayFactor × PR duration
350+ var remodelDeliveryDelayCost float64
351+ for range breakdowns {
352+ remodelDeliveryDelayCost += hourlyRate * cfg .DeliveryDelayFactor * targetMergeTimeHours
353+ }
354+ extRemodelDeliveryDelayCost := remodelDeliveryDelayCost / samples * multiplier
355+
356+ // Recalculate code churn with 40-minute PRs
357+ // Code churn is proportional to PR duration (rework percentage increases with time)
358+ // For 40 minutes, rework percentage would be minimal (< 1 day, so ~0%)
359+ extRemodelCodeChurnCost := 0.0 // 40 minutes is too short for meaningful code churn
360+
361+ // Recalculate automated updates cost
362+ // Automated updates are calculated based on PR duration
363+ // With 40-minute PRs, no bot updates would be needed (happens after 1 day)
364+ extRemodelAutomatedUpdatesCost := 0.0 // 40 minutes is too short for automated updates
365+
366+ // Recalculate PR tracking cost
367+ // With faster merge times, we'd have fewer open PRs at any given time
368+ // Estimate: if current avg is X hours, and we reduce to 40 min, open PRs would be (40min / X hours) of current
369+ var extRemodelPRTrackingCost float64
370+ var currentAvgOpenTime float64
371+ if successfulSamples > 0 {
372+ currentAvgOpenTime = sumPRDuration / samples
373+ }
374+ if currentAvgOpenTime > 0 {
375+ openPRReductionRatio := targetMergeTimeHours / currentAvgOpenTime
376+ extRemodelPRTrackingCost = extPRTrackingCost * openPRReductionRatio
377+ } else {
378+ extRemodelPRTrackingCost = 0.0
379+ }
380+
381+ // Calculate re-modeled annual waste
382+ remodelPreventablePerPeriod := extRemodelDeliveryDelayCost + extRemodelCodeChurnCost + extRemodelAutomatedUpdatesCost + extRemodelPRTrackingCost
383+ remodelAnnualWaste := remodelPreventablePerPeriod * (52.0 / (float64 (daysInPeriod ) / 7.0 ))
384+
385+ // Subtract R2R subscription cost: $4/mo * 12 months * unique user count
386+ r2rAnnualCost := 4.0 * 12.0 * float64 (uniqueUserCount )
387+
388+ // Calculate savings
389+ r2rSavings := baselineAnnualWaste - remodelAnnualWaste - r2rAnnualCost
390+ if r2rSavings < 0 {
391+ r2rSavings = 0 // Don't show negative savings
392+ }
393+
325394 return ExtrapolatedBreakdown {
326395 TotalPRs : totalPRs ,
327396 HumanPRs : extHumanPRs ,
@@ -390,5 +459,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
390459
391460 TotalCost : extTotalCost ,
392461 TotalHours : extTotalHours ,
462+
463+ UniqueNonBotUsers : uniqueUserCount ,
464+ R2RSavings : r2rSavings ,
393465 }
394466}
0 commit comments