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..e98f312 100644
--- a/src/report/web/App.browser.test.tsx
+++ b/src/report/web/App.browser.test.tsx
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import { afterEach, describe, expect, it } from 'vitest';
import type { Analytics } from '../../Analytics.ts';
import { cveExposureOk, embeddedReportData } from '../testFactories.ts';
-import { automatedStoryTestIds } from './acts/AutomatedStory.tsx';
+import { automatedStoryCopy, automatedStoryTestIds } from './acts/AutomatedStory.tsx';
import { callToActionCopy, callToActionTestIds } from './acts/CallToAction.tsx';
import { costStoryCopy, costStoryTestIds } from './acts/CostStory.tsx';
import { methodologyAppendixTestIds } from './acts/MethodologyAppendix.tsx';
@@ -23,55 +23,80 @@ 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.
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('not including the 102 still open');
+ expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument();
+ expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as time');
+ expect(screen.queryByTestId(assumptionInputTestIds.hourlyRateUsd)).not.toBeInTheDocument();
});
it('recalculates the headline cost and comparison cards when assumptions change', () => {
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('toggles report savings from time to cost', () => {
+ renderReport();
+ const assumptions = screen.getByTestId(assumptionInputTestIds.container);
+
+ fireEvent.click(within(assumptions).getByTestId(assumptionInputTestIds.displayCost));
+
+ expect(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRateUsd)).toBeInTheDocument();
+ expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as cost');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$40,560/year');
+ expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('$10,000');
+ expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('$3,380/mo');
+ expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$40,560/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$40,560/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$24,336/yr');
+
+ const table = screen.getByTestId(costStoryTestIds.peopleTable);
+ expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument();
+ expect(within(table).getByText('alice').closest('tr')).toHaveTextContent('$6,400');
});
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', () => {
@@ -187,8 +212,13 @@ describe('App report shell', () => {
expect(screen.getByTestId(openPrAgeStoryTestIds.section)).toHaveTextContent(openPrAgeStoryCopy.heading);
expect(screen.getByTestId(riskStoryTestIds.section)).toHaveTextContent(riskStoryCopy.eyebrow);
expect(screen.getByTestId(callToActionTestIds.section)).toHaveTextContent(callToActionCopy.heading);
- expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta);
- expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default');
+ expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveTextContent(automatedStoryCopy.secondaryCta);
+ expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveAttribute('data-variant', 'secondary');
+ expect(
+ screen
+ .getByTestId(automatedStoryTestIds.waitlistCta)
+ .compareDocumentPosition(screen.getByTestId(automatedStoryTestIds.secondaryCta)),
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel);
fireEvent.click(screen.getByText('How this report was calculated'));
@@ -206,12 +236,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 +270,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 +280,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 +305,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', () => {
@@ -367,15 +398,15 @@ describe('App analytics', () => {
cleanup();
});
- it('captures cta_clicked when the verdict primary CTA is clicked', () => {
+ it('captures cta_clicked when the automated story secondary CTA is clicked', () => {
const { analytics, events } = createFakeAnalytics();
renderWithAnalytics(analytics);
const restore = suppressNavigation();
- fireEvent.click(screen.getByTestId(verdictTestIds.primaryCta));
+ fireEvent.click(screen.getByTestId(automatedStoryTestIds.secondaryCta));
restore();
- expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'verdict_primary' } });
+ expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'automated_story_secondary' } });
});
it('captures cta_clicked when the call-to-action CTA is clicked', () => {
@@ -393,14 +424,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..8d28baa 100644
--- a/src/report/web/acts/AutomatedStory.tsx
+++ b/src/report/web/acts/AutomatedStory.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
import { Button } from '../components/ui/button.tsx';
+import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
import { Citation } from '../primitives/Citation.tsx';
@@ -13,12 +14,17 @@ export const automatedStoryTestIds = {
delta: 'automated-story-delta',
shareSlider: 'automated-story-share-slider',
waitlistCta: 'automated-story-waitlist-cta',
+ secondaryCta: 'automated-story-secondary-cta',
} as const;
-const SHARE_MIN = 50;
-const SHARE_MAX = 80;
+export const automatedStoryCopy = {
+ secondaryCta: 'See how PatchWave helps',
+} as const;
+
+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 +32,15 @@ const SHARE_STOPS = Array.from(
);
export function AutomatedStory() {
- const { assumptions, derived } = useAssumptions();
+ const { assumptions, displayMode, derived } = 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));
+ const patchwaveSavingsUsd = Math.round(derived.annualCostUsd * (sharePct / 100));
return (
@@ -51,8 +60,12 @@ export function AutomatedStory() {
@@ -111,15 +126,26 @@ export function AutomatedStory() {
.
-
+
+
+
+
);
}
@@ -151,14 +177,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..1805a8b 100644
--- a/src/report/web/acts/CostStory.tsx
+++ b/src/report/web/acts/CostStory.tsx
@@ -22,9 +22,12 @@ export const costStoryCopy = {
export function CostStory() {
const data = useEmbeddedData();
- const { assumptions, derived } = useAssumptions();
+ const { assumptions, displayMode, 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 +42,31 @@ 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
+ {displayMode === 'cost' ? (
+ <>
+ {' '}
+ and ${assumptions.hourlyRateUsd}/hr
+ >
+ ) : null}
+ {', '}that comes out to:
@@ -65,7 +74,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.