Skip to content

Commit aced381

Browse files
committed
Show final totals
1 parent 4709e7a commit aced381

5 files changed

Lines changed: 553 additions & 61 deletions

File tree

cmd/prcost/main.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
282282
}
283283
// Only show other events if they had non-review events
284284
if p.GitHubHours > 0 {
285-
fmt.Printf(" GitHub Events %12s %d sessions • %s\n",
285+
fmt.Printf(" GitHub Activity %12s %d sessions • %s\n",
286286
formatCurrency(p.GitHubCost), p.Sessions, formatTimeUnit(p.GitHubHours))
287287
}
288288
// Always show context switching if there were sessions
@@ -312,6 +312,9 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
312312
fmt.Printf(" Total %12s %s\n",
313313
formatCurrency(breakdown.TotalCost), formatTimeUnit(totalHours))
314314
fmt.Println()
315+
316+
// Print efficiency score
317+
printEfficiency(breakdown, formatCurrency)
315318
}
316319

317320
// printDelayCosts prints delay and future costs section.
@@ -325,7 +328,7 @@ func printDelayCosts(breakdown *cost.Breakdown, formatCurrency func(float64) str
325328
if breakdown.DelayCapped {
326329
cappedSuffix = " (capped)"
327330
}
328-
fmt.Printf(" Project %12s %s%s\n",
331+
fmt.Printf(" Workstream blockage %12s %s%s\n",
329332
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost),
330333
formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours),
331334
cappedSuffix)
@@ -431,3 +434,76 @@ func formatWithCommas(amount float64) string {
431434

432435
return string(result) + "." + decPart
433436
}
437+
438+
// efficiencyGrade returns a letter grade and message based on efficiency percentage (MIT scale).
439+
func efficiencyGrade(efficiencyPct float64) (string, string) {
440+
switch {
441+
case efficiencyPct >= 97:
442+
return "A+", "Outstanding!"
443+
case efficiencyPct >= 93:
444+
return "A", "Excellent!"
445+
case efficiencyPct >= 90:
446+
return "A-", "Great work!"
447+
case efficiencyPct >= 87:
448+
return "B+", "Pretty good!"
449+
case efficiencyPct >= 83:
450+
return "B", "Good work!"
451+
case efficiencyPct >= 80:
452+
return "B-", "Room for improvement."
453+
case efficiencyPct >= 77:
454+
return "C+", "Some inefficiency."
455+
case efficiencyPct >= 73:
456+
return "C", "Needs work."
457+
case efficiencyPct >= 70:
458+
return "C-", "Optimize your workflow."
459+
case efficiencyPct >= 67:
460+
return "D+", "Major inefficiency."
461+
case efficiencyPct >= 63:
462+
return "D", "Critical issues."
463+
case efficiencyPct >= 60:
464+
return "D-", "Severe inefficiency."
465+
default:
466+
return "F", "Rethink your workflow."
467+
}
468+
}
469+
470+
// printEfficiency prints the workflow efficiency section for a single PR.
471+
func printEfficiency(breakdown *cost.Breakdown, formatCurrency func(float64) string) {
472+
// Calculate preventable waste: Code Churn + All Delay Costs
473+
preventableHours := breakdown.DelayCostDetail.CodeChurnHours +
474+
breakdown.DelayCostDetail.DeliveryDelayHours +
475+
breakdown.DelayCostDetail.CoordinationHours
476+
preventableCost := breakdown.DelayCostDetail.CodeChurnCost +
477+
breakdown.DelayCostDetail.DeliveryDelayCost +
478+
breakdown.DelayCostDetail.CoordinationCost
479+
480+
// Calculate total hours
481+
totalHours := breakdown.Author.TotalHours + breakdown.DelayCostDetail.TotalDelayHours
482+
for _, p := range breakdown.Participants {
483+
totalHours += p.TotalHours
484+
}
485+
486+
// Calculate efficiency
487+
var efficiencyPct float64
488+
if totalHours > 0 {
489+
efficiencyPct = 100.0 * (totalHours - preventableHours) / totalHours
490+
} else {
491+
efficiencyPct = 100.0
492+
}
493+
494+
grade, message := efficiencyGrade(efficiencyPct)
495+
496+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
497+
fmt.Printf(" │ WORKFLOW EFFICIENCY: %s (%.1f%%) - %s", grade, efficiencyPct, message)
498+
// Calculate padding to reach 63 chars (box width)
499+
headerLen := 25 + len(grade) + len(fmt.Sprintf("%.1f", efficiencyPct)) + len(message)
500+
padding := 63 - headerLen
501+
if padding < 0 {
502+
padding = 0
503+
}
504+
fmt.Printf("%*s│\n", padding, "")
505+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
506+
fmt.Printf(" Preventable Waste: $%12s %s\n",
507+
formatWithCommas(preventableCost), formatTimeUnit(preventableHours))
508+
fmt.Println()
509+
}

cmd/prcost/repository.go

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,38 @@ func formatTimeUnit(hours float64) string {
205205
return fmt.Sprintf("%.1f years", years)
206206
}
207207

208+
// formatEngTimeUnit formats time for annual metrics.
209+
// Uses standard time units (months, years) for large durations.
210+
func formatEngTimeUnit(hours float64) string {
211+
// Show minutes for values less than 1 hour
212+
if hours < 1.0 {
213+
minutes := hours * 60.0
214+
return fmt.Sprintf("%.1f min", minutes)
215+
}
216+
217+
if hours < 48 {
218+
return fmt.Sprintf("%.1f hrs", hours)
219+
}
220+
221+
days := hours / 24.0
222+
if days < 14 {
223+
return fmt.Sprintf("%.1f days", days)
224+
}
225+
226+
weeks := days / 7.0
227+
if weeks < 8 {
228+
return fmt.Sprintf("%.1f weeks", weeks)
229+
}
230+
231+
months := days / 30.0
232+
if months < 24 {
233+
return fmt.Sprintf("%.1f months", months)
234+
}
235+
236+
years := days / 365.0
237+
return fmt.Sprintf("%.1f years", years)
238+
}
239+
208240
// printExtrapolatedResults displays extrapolated cost breakdown in itemized format.
209241
//
210242
//nolint:maintidx,revive // acceptable complexity/length for comprehensive display function
@@ -264,7 +296,7 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
264296
formatWithCommas(avgAuthorGitHubContextCost), formatTimeUnit(avgAuthorGitHubContextHours))
265297
fmt.Println(" ────────────")
266298
pct := (avgAuthorTotalCost / avgTotalCost) * 100
267-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
299+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
268300
formatWithCommas(avgAuthorTotalCost), formatTimeUnit(avgAuthorTotalHours), pct)
269301
fmt.Println()
270302

@@ -277,30 +309,30 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
277309
formatWithCommas(avgParticipantReviewCost), formatTimeUnit(avgParticipantReviewHours))
278310
}
279311
if avgParticipantGitHubCost > 0 {
280-
fmt.Printf(" GitHub Events %12s %s\n",
312+
fmt.Printf(" GitHub Activity %12s %s\n",
281313
formatWithCommas(avgParticipantGitHubCost), formatTimeUnit(avgParticipantGitHubHours))
282314
}
283315
fmt.Printf(" Context Switching %12s %s\n",
284316
formatWithCommas(avgParticipantContextCost), formatTimeUnit(avgParticipantContextHours))
285317
fmt.Println(" ────────────")
286318
pct := (avgParticipantTotalCost / avgTotalCost) * 100
287-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
319+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
288320
formatWithCommas(avgParticipantTotalCost), formatTimeUnit(avgParticipantTotalHours), pct)
289321
fmt.Println()
290322
}
291323

292324
// Merge Delay section
293325
fmt.Println(" Delay Costs")
294326
fmt.Println(" ───────────")
295-
fmt.Printf(" Project %12s %s\n",
327+
fmt.Printf(" Workstream blockage %12s %s\n",
296328
formatWithCommas(avgDeliveryDelayCost), formatTimeUnit(avgDeliveryDelayHours))
297329
fmt.Printf(" Coordination %12s %s\n",
298330
formatWithCommas(avgCoordinationCost), formatTimeUnit(avgCoordinationHours))
299331
avgMergeDelayCost := avgDeliveryDelayCost + avgCoordinationCost
300332
avgMergeDelayHours := avgDeliveryDelayHours + avgCoordinationHours
301333
fmt.Println(" ────────────")
302334
pct = (avgMergeDelayCost / avgTotalCost) * 100
303-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
335+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
304336
formatWithCommas(avgMergeDelayCost), formatTimeUnit(avgMergeDelayHours), pct)
305337
fmt.Println()
306338

@@ -341,22 +373,23 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
341373
avgFutureHours := avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours
342374
fmt.Println(" ────────────")
343375
pct = (avgFutureCost / avgTotalCost) * 100
344-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
376+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
345377
formatWithCommas(avgFutureCost), formatTimeUnit(avgFutureHours), pct)
346378
fmt.Println()
347379
}
348380

349381
// Average total
350382
fmt.Println(" ═══════════════════════════════════════════════════════════════")
351-
fmt.Printf(" Average Total %12s %s\n",
383+
fmt.Printf(" Average Total $%12s %s\n",
352384
formatWithCommas(avgTotalCost), formatTimeUnit(avgTotalHours))
353385
fmt.Println()
354386
fmt.Println()
355387

356388
// Extrapolated total section with improved visual hierarchy
357389
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
358-
fmt.Printf(" │ Estimated Total Changes (extrapolated across %d day period)%*s│\n", days, 35-len(strconv.Itoa(days)), "")
359-
fmt.Printf(" │ Total PRs: %d%*s│\n", ext.TotalPRs, 60-len(strconv.Itoa(ext.TotalPRs)), "")
390+
headerText := fmt.Sprintf("Estimated costs within a %d day period (extrapolated)", days)
391+
padding := 61 - len(headerText)
392+
fmt.Printf(" │ %s%*s│\n", headerText, padding, "")
360393
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
361394
fmt.Println()
362395

@@ -373,7 +406,7 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
373406
formatWithCommas(ext.AuthorGitHubContextCost), formatTimeUnit(ext.AuthorGitHubContextHours))
374407
fmt.Println(" ────────────")
375408
pct = (ext.AuthorTotalCost / ext.TotalCost) * 100
376-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
409+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
377410
formatWithCommas(ext.AuthorTotalCost), formatTimeUnit(ext.AuthorTotalHours), pct)
378411
fmt.Println()
379412

@@ -386,30 +419,30 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
386419
formatWithCommas(ext.ParticipantReviewCost), formatTimeUnit(ext.ParticipantReviewHours))
387420
}
388421
if ext.ParticipantGitHubCost > 0 {
389-
fmt.Printf(" GitHub Events %12s %s\n",
422+
fmt.Printf(" GitHub Activity %12s %s\n",
390423
formatWithCommas(ext.ParticipantGitHubCost), formatTimeUnit(ext.ParticipantGitHubHours))
391424
}
392425
fmt.Printf(" Context Switching %12s %s\n",
393426
formatWithCommas(ext.ParticipantContextCost), formatTimeUnit(ext.ParticipantContextHours))
394427
fmt.Println(" ────────────")
395428
pct = (ext.ParticipantTotalCost / ext.TotalCost) * 100
396-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
429+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
397430
formatWithCommas(ext.ParticipantTotalCost), formatTimeUnit(ext.ParticipantTotalHours), pct)
398431
fmt.Println()
399432
}
400433

401434
// Merge Delay section (extrapolated)
402435
fmt.Println(" Delay Costs")
403436
fmt.Println(" ───────────")
404-
fmt.Printf(" Project %12s %s\n",
437+
fmt.Printf(" Workstream blockage %12s %s\n",
405438
formatWithCommas(ext.DeliveryDelayCost), formatTimeUnit(ext.DeliveryDelayHours))
406439
fmt.Printf(" Coordination %12s %s\n",
407440
formatWithCommas(ext.CoordinationCost), formatTimeUnit(ext.CoordinationHours))
408441
extMergeDelayCost := ext.DeliveryDelayCost + ext.CoordinationCost
409442
extMergeDelayHours := ext.DeliveryDelayHours + ext.CoordinationHours
410443
fmt.Println(" ────────────")
411444
pct = (extMergeDelayCost / ext.TotalCost) * 100
412-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
445+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
413446
formatWithCommas(extMergeDelayCost), formatTimeUnit(extMergeDelayHours), pct)
414447
fmt.Println()
415448

@@ -443,14 +476,55 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
443476
extFutureHours := ext.CodeChurnHours + ext.FutureReviewHours + ext.FutureMergeHours + ext.FutureContextHours
444477
fmt.Println(" ────────────")
445478
pct = (extFutureCost / ext.TotalCost) * 100
446-
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
479+
fmt.Printf(" Subtotal $%12s %s (%.1f%%)\n",
447480
formatWithCommas(extFutureCost), formatTimeUnit(extFutureHours), pct)
448481
fmt.Println()
449482
}
450483

451484
// Extrapolated grand total
452485
fmt.Println(" ═══════════════════════════════════════════════════════════════")
453-
fmt.Printf(" Total %12s %s\n",
486+
fmt.Printf(" Total $%12s %s\n",
454487
formatWithCommas(ext.TotalCost), formatTimeUnit(ext.TotalHours))
455488
fmt.Println()
489+
490+
// Print extrapolated efficiency score + annual waste
491+
printExtrapolatedEfficiency(ext, days)
492+
}
493+
494+
// printExtrapolatedEfficiency prints the workflow efficiency + annual waste section for extrapolated totals.
495+
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int) {
496+
// Calculate preventable waste: Code Churn + All Delay Costs
497+
preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.CoordinationHours
498+
preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.CoordinationCost
499+
500+
// Calculate efficiency
501+
var efficiencyPct float64
502+
if ext.TotalHours > 0 {
503+
efficiencyPct = 100.0 * (ext.TotalHours - preventableHours) / ext.TotalHours
504+
} else {
505+
efficiencyPct = 100.0
506+
}
507+
508+
grade, message := efficiencyGrade(efficiencyPct)
509+
510+
// Calculate annual waste
511+
annualMultiplier := 365.0 / float64(days)
512+
annualWasteHours := preventableHours * annualMultiplier
513+
annualWasteCost := preventableCost * annualMultiplier
514+
515+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
516+
fmt.Printf(" │ WORKFLOW EFFICIENCY: %s (%.1f%%) - %s", grade, efficiencyPct, message)
517+
// Calculate padding to reach 63 chars (box width)
518+
headerLen := 25 + len(grade) + len(fmt.Sprintf("%.1f", efficiencyPct)) + len(message)
519+
padding := 63 - headerLen
520+
if padding < 0 {
521+
padding = 0
522+
}
523+
fmt.Printf("%*s│\n", padding, "")
524+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
525+
fmt.Printf(" Preventable Waste: $%12s %s\n",
526+
formatWithCommas(preventableCost), formatTimeUnit(preventableHours))
527+
fmt.Printf(" Annual Wasted Effort: $%12s %s\n",
528+
formatWithCommas(annualWasteCost), formatEngTimeUnit(annualWasteHours))
529+
fmt.Println()
456530
}

internal/server/server.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,15 +1098,18 @@ func (s *Server) parseRepoSampleRequest(ctx context.Context, r *http.Request) (*
10981098

10991099
// Set defaults
11001100
if req.SampleSize == 0 {
1101-
req.SampleSize = 20
1101+
req.SampleSize = 25
11021102
}
11031103
if req.Days == 0 {
11041104
req.Days = 90
11051105
}
11061106

1107-
// Validate reasonable limits
1108-
if req.SampleSize < 1 || req.SampleSize > 1000 {
1109-
return nil, errors.New("sample_size must be between 1 and 1000")
1107+
// Validate reasonable limits (silently cap at 25)
1108+
if req.SampleSize < 1 {
1109+
return nil, errors.New("sample_size must be at least 1")
1110+
}
1111+
if req.SampleSize > 25 {
1112+
req.SampleSize = 25
11101113
}
11111114
if req.Days < 1 || req.Days > 365 {
11121115
return nil, errors.New("days must be between 1 and 365")
@@ -1132,15 +1135,18 @@ func (s *Server) parseOrgSampleRequest(ctx context.Context, r *http.Request) (*O
11321135

11331136
// Set defaults
11341137
if req.SampleSize == 0 {
1135-
req.SampleSize = 20
1138+
req.SampleSize = 25
11361139
}
11371140
if req.Days == 0 {
11381141
req.Days = 90
11391142
}
11401143

1141-
// Validate reasonable limits
1142-
if req.SampleSize < 1 || req.SampleSize > 1000 {
1143-
return nil, errors.New("sample_size must be between 1 and 1000")
1144+
// Validate reasonable limits (silently cap at 25)
1145+
if req.SampleSize < 1 {
1146+
return nil, errors.New("sample_size must be at least 1")
1147+
}
1148+
if req.SampleSize > 25 {
1149+
req.SampleSize = 25
11441150
}
11451151
if req.Days < 1 || req.Days > 365 {
11461152
return nil, errors.New("days must be between 1 and 365")

0 commit comments

Comments
 (0)