From 2378979abffb0e431a0bc7a465422c60bd3e61be Mon Sep 17 00:00:00 2001
From: Josh Carver
Date: Thu, 28 May 2026 10:40:23 -0700
Subject: [PATCH 1/2] refactor: Show time savings
---
src/report/aggregate.test.ts | 16 ++--
src/report/costFormulas.ts | 4 +-
src/report/testFactories.ts | 6 +-
src/report/web/App.browser.test.tsx | 77 ++++++++++---------
src/report/web/acts/AutomatedStory.tsx | 37 +++++----
src/report/web/acts/CostStory.tsx | 39 ++++++----
src/report/web/acts/MethodologyAppendix.tsx | 28 +++----
src/report/web/acts/Verdict.tsx | 20 +++--
src/report/web/assumptionFields.ts | 1 -
src/report/web/hooks/useAssumptions.tsx | 24 ++----
src/report/web/primitives/AssumptionInput.tsx | 10 +--
src/report/web/primitives/HeroAssumptions.tsx | 7 +-
src/report/web/primitives/PersonRow.tsx | 10 +--
13 files changed, 131 insertions(+), 148 deletions(-)
diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts
index 3804333..867686b 100644
--- a/src/report/aggregate.test.ts
+++ b/src/report/aggregate.test.ts
@@ -214,14 +214,16 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges
expect(bundle.costEstimate.humanMergeCount).toBe(100);
expect(bundle.costEstimate.humanReviewCount).toBe(0);
expect(bundle.costEstimate.hourlyRateUsd).toBe(200);
- expect(bundle.costEstimate.minutesPerPr).toBe(12);
- // 100 actions × 12 min × $200/hr / 60 = $4000 in window
- expect(bundle.costEstimate.windowCostUsd).toBe(4000);
- // ~$1,352/month over 90 days (window × 30.44/90)
- expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1300);
- expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1400);
+ expect(bundle.costEstimate.minutesPerPr).toBe(10);
+ // 100 actions × 10 min × $200/hr / 60 = $3333 in window
+ expect(bundle.costEstimate.windowCostUsd).toBe(3333);
+ // ~$1,126/month over 90 days (window × 30.44/90)
+ expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1100);
+ expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1150);
expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12);
- expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]);
+ expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([
+ 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9,
+ ]);
expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe(
(bundle.costEstimate.savingsScenarios[0]?.monthlySavingsUsd ?? 0) * 12,
);
diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts
index f2590a6..aa3e2b7 100644
--- a/src/report/costFormulas.ts
+++ b/src/report/costFormulas.ts
@@ -3,9 +3,9 @@
// so it bundles cleanly into both targets.
export const ASSUMED_HOURLY_RATE_USD = 200;
-export const ASSUMED_MIN_PER_PR = 12;
+export const ASSUMED_MIN_PER_PR = 10;
-export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const;
+export const AUTO_MERGE_SCENARIO_RATES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] as const;
const DAYS_PER_MONTH = 365 / 12;
diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts
index 81353da..3d79224 100644
--- a/src/report/testFactories.ts
+++ b/src/report/testFactories.ts
@@ -80,14 +80,14 @@ export const stalledSignals = Factory.define(() => ({
// Cost figures derive from the real defaults and formulas so the fixtures track
// production whenever the assumptions move, rather than restating stale literals.
const COST_WINDOW_DAYS = 90;
-const HUMAN_MERGE_COUNT = 150;
+const HUMAN_MERGE_COUNT = 288;
const HUMAN_REVIEW_COUNT = 12;
export const people = Factory.define(() => ({
mergers: derivePersonCosts(
[
- { login: 'alice', count: 90 },
- { login: 'bob', count: 60 },
+ { login: 'alice', count: 180 },
+ { login: 'bob', count: 108 },
],
COST_WINDOW_DAYS,
ASSUMED_MIN_PER_PR,
diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx
index a9c2c0f..8fc1442 100644
--- a/src/report/web/App.browser.test.tsx
+++ b/src/report/web/App.browser.test.tsx
@@ -23,7 +23,7 @@ describe('App report shell', () => {
it('renders the headline annual cost from the embedded data', () => {
renderReport();
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$26,280/year');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('50 engineer-hours/quarter');
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costLeadIn);
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costTrailer);
// The headline clarifies it excludes the open backlog, which lives in its own section.
@@ -34,44 +34,46 @@ describe('App report shell', () => {
renderReport();
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
- fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate), {
- target: { value: '300' },
+ fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
+ target: { value: '12' },
});
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$39,420/year');
- expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$39,420/yr');
- // "Today" mirrors the headline; "PatchWave savings" is the recovered cost at the default 65% share.
- expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$39,420/yr');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$25,623/yr');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter');
+ expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('60 hrs');
+ expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('20 hrs/mo');
+ expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('60 hrs/qtr');
+ // The automation comparison defaults to recovered engineer hours per quarter.
+ expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('~60 hrs/qtr');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~36 hrs/qtr');
});
it('allows replacing an assumption value by clearing and typing', () => {
renderReport();
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
- const hourlyRateInput = within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate);
+ const minutesInput = within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr);
- fireEvent.focus(hourlyRateInput);
- fireEvent.change(hourlyRateInput, { target: { value: '' } });
- expect(hourlyRateInput).toHaveValue('');
+ fireEvent.focus(minutesInput);
+ fireEvent.change(minutesInput, { target: { value: '' } });
+ expect(minutesInput).toHaveValue('');
- fireEvent.change(hourlyRateInput, { target: { value: '275' } });
- fireEvent.blur(hourlyRateInput);
+ fireEvent.change(minutesInput, { target: { value: '12' } });
+ fireEvent.blur(minutesInput);
- expect(hourlyRateInput).toHaveValue('275');
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$36,132/year');
+ expect(minutesInput).toHaveValue('12');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter');
});
it('recalculates the PatchWave savings card when the auto-merge share changes', () => {
renderReport();
- // Default 65% share starts in the middle of the modeled range.
- expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,082/yr');
+ // Default 60% share matches PatchWave's public calculator assumption.
+ expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('60%');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~30 hrs/qtr');
fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } });
expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$13,140/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~25 hrs/qtr');
});
it('lists footnotes in ascending first-appearance order', () => {
@@ -206,12 +208,12 @@ describe('App report shell', () => {
renderReport();
const table = screen.getByTestId(costStoryTestIds.peopleTable);
- expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument();
+ expect(within(table).getByRole('columnheader', { name: 'Time over last 90 days' })).toBeInTheDocument();
const aliceCells = within(table).getAllByText('alice');
expect(aliceCells).toHaveLength(1);
// alice merged and reviewed, so her two activity rows collapse into one combined row.
const aliceRow = aliceCells[0]?.closest('tr');
- expect(aliceRow).toHaveTextContent('90');
+ expect(aliceRow).toHaveTextContent('180');
expect(aliceRow).toHaveTextContent('merged');
expect(aliceRow).toHaveTextContent('12');
expect(aliceRow).toHaveTextContent('reviewed');
@@ -240,7 +242,7 @@ describe('App report shell', () => {
expect(within(table).getByText('person-6')).toBeInTheDocument();
});
- it('reworks per-person review costs when the minutes-per-PR assumption changes', () => {
+ it('reworks per-person review time when the minutes-per-PR assumption changes', () => {
renderReport({
people: {
mergers: [],
@@ -250,20 +252,20 @@ describe('App report shell', () => {
});
const table = screen.getByTestId(costStoryTestIds.peopleTable);
- // 10 reviews x 12 min x $200/hr / 60 = $400 in window at the defaults.
- expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$400');
+ // 10 reviews x 10 min / 60 = ~2 hours in window at the defaults.
+ expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('2');
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
- target: { value: '10' },
+ target: { value: '20' },
});
- // Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the cost:
- // 10 reviews x 10 min x $200/hr / 60 = $333.
- expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$333');
+ // Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the time:
+ // 10 reviews x 20 min / 60 = ~3 hours.
+ expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('3');
});
- it('reworks the raw-data per-person costs when an assumption changes', () => {
+ it('keeps raw-data people rows count-only when an assumption changes', () => {
renderReport({
people: {
mergers: [],
@@ -275,15 +277,16 @@ describe('App report shell', () => {
// The assumptions control lives in the hero, so it stays editable regardless of the appendix tab.
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
- target: { value: '10' },
+ target: { value: '20' },
});
fireEvent.click(screen.getByText('How this report was calculated'));
fireEvent.click(screen.getByRole('tab', { name: 'Raw data' }));
- // 10 reviews x 10 min x $200/hr / 60 = $333 window, annualized to $1,351/yr.
const rawData = screen.getByTestId(methodologyAppendixTestIds.rawData);
- expect(within(rawData).getByText('carol').closest('li')).toHaveTextContent('$1,351/yr');
+ const row = within(rawData).getByText('carol').closest('li');
+ expect(row).toHaveTextContent('10 reviewed');
+ expect(row).not.toHaveTextContent('$');
});
it('renders the open PR age buckets as a separate section with count-only rows', () => {
@@ -393,14 +396,14 @@ describe('App analytics', () => {
const { analytics, events } = createFakeAnalytics();
renderWithAnalytics(analytics);
- const input = screen.getAllByTestId(assumptionInputTestIds.hourlyRate)[0];
- if (!input) throw new Error('missing hourly rate input');
- fireEvent.change(input, { target: { value: '275' } });
+ const input = screen.getAllByTestId(assumptionInputTestIds.minutesPerPr)[0];
+ if (!input) throw new Error('missing minutes per PR input');
+ fireEvent.change(input, { target: { value: '12' } });
fireEvent.blur(input);
expect(events).toContainEqual({
event: 'assumption_changed',
- properties: { field: 'hourly_rate', value: 275 },
+ properties: { field: 'minutes_per_pr', value: 12 },
});
});
});
diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx
index bf8fd2f..5bfaeb3 100644
--- a/src/report/web/acts/AutomatedStory.tsx
+++ b/src/report/web/acts/AutomatedStory.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
import { Button } from '../components/ui/button.tsx';
-import { fmtUsd } from '../format/money.ts';
+import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
import { Citation } from '../primitives/Citation.tsx';
import { callToActionCopy } from './CallToAction.tsx';
@@ -15,10 +15,10 @@ export const automatedStoryTestIds = {
waitlistCta: 'automated-story-waitlist-cta',
} as const;
-const SHARE_MIN = 50;
-const SHARE_MAX = 80;
+const SHARE_MIN = 10;
+const SHARE_MAX = 90;
const SHARE_STEP = 5;
-const SHARE_DEFAULT = 65;
+const SHARE_DEFAULT = 60;
const SHARE_MID = (SHARE_MIN + SHARE_MAX) / 2;
const SHARE_STOPS = Array.from(
{ length: (SHARE_MAX - SHARE_MIN) / SHARE_STEP + 1 },
@@ -26,12 +26,14 @@ const SHARE_STOPS = Array.from(
);
export function AutomatedStory() {
- const { assumptions, derived } = useAssumptions();
+ const { assumptions } = useAssumptions();
+ const { costEstimate } = useEmbeddedData();
const analytics = useAnalytics();
const [sharePct, setSharePct] = useState(SHARE_DEFAULT);
- const todayCost = derived.annualCostUsd;
- const patchwaveSavings = Math.round(todayCost * (sharePct / 100));
+ const totalActions = costEstimate.humanMergeCount + costEstimate.humanReviewCount;
+ const quarterlyHours = actionsToHours(totalActions, assumptions.minutesPerPr);
+ const patchwaveSavingsHours = Math.round(quarterlyHours * (sharePct / 100));
return (
@@ -51,8 +53,8 @@ export function AutomatedStory() {
@@ -151,14 +153,11 @@ function CompareCard({
);
}
-function quarterHoursLabel(costUsd: number, hourlyRateUsd: number): string {
- const hours = Math.round(costUsd / hourlyRateUsd / 4);
- if (hours <= 0) return 'under an hour of engineer time per quarter';
- return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time per quarter`;
+function actionsToHours(count: number, minutesPerPr: number): number {
+ return Math.round((count * minutesPerPr) / 60);
}
-function quarterHoursSavedLabel(savingsUsd: number, hourlyRateUsd: number): string {
- const hours = Math.round(savingsUsd / hourlyRateUsd / 4);
- if (hours <= 0) return 'under an hour of engineer time saved per quarter';
- return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time saved per quarter`;
+function fmtHoursPerQuarter(hours: number): string {
+ if (hours <= 0) return '<1 hr/qtr';
+ return `~${hours.toLocaleString()} ${hours === 1 ? 'hr' : 'hrs'}/qtr`;
}
diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx
index 0acc250..949dc60 100644
--- a/src/report/web/acts/CostStory.tsx
+++ b/src/report/web/acts/CostStory.tsx
@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
-import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
import { PersonRow } from '../primitives/PersonRow.tsx';
@@ -24,7 +23,10 @@ export function CostStory() {
const data = useEmbeddedData();
const { assumptions, derived } = useAssumptions();
- const { humanMergeCount } = data.costEstimate;
+ const { humanMergeCount, humanReviewCount } = data.costEstimate;
+ const totalActions = humanMergeCount + humanReviewCount;
+ const windowHours = actionsToHours(totalActions, assumptions.minutesPerPr);
+ const monthlyHours = (windowHours * (365 / 12)) / data.costEstimate.windowDays;
return (
@@ -39,25 +41,25 @@ export function CostStory() {
In the last {data.meta.windowDays} days, your team merged{' '}
{humanMergeCount.toLocaleString()} Dependabot PRs by hand.
Anything a bot auto-merged is left out. At{' '}
- {assumptions.minutesPerPr} minutes per PR and{' '}
- ${assumptions.hourlyRateUsd}/hr, that comes out to:
+ {assumptions.minutesPerPr} minutes per PR, that comes out
+ to:
@@ -65,7 +67,7 @@ export function CostStory() {
- The 12 min/PR default covers the context switch, review, and merge for a single PR. Anything a bot merged is
+ The 10 min/PR default covers the context switch, review, and merge for a single PR. Anything a bot merged is
left out, so these totals only count human effort.