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.

@@ -93,8 +102,17 @@ function CostCell({ ); } +function actionsToHours(count: number, minutesPerPr: number): number { + return (count * minutesPerPr) / 60; +} + +function fmtHours(hours: number): string { + const rounded = Math.round(hours); + return `${rounded.toLocaleString()} ${rounded === 1 ? 'hr' : 'hrs'}`; +} + function PeopleTable({ windowDays }: { windowDays: number }) { - const { assumptions, derived } = useAssumptions(); + const { assumptions, displayMode, derived } = useAssumptions(); const [expanded, setExpanded] = useState(false); const people = combinedPeopleRows(derived.mergers, derived.reviewers); const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT); @@ -119,9 +137,9 @@ function PeopleTable({ windowDays }: { windowDays: number }) { Person Count - Time (hrs) - Cost over last {windowDays} days - Annualized + + {displayMode === 'time' ? `Time over last ${windowDays} days` : `Cost over last ${windowDays} days`} + @@ -131,14 +149,18 @@ function PeopleTable({ windowDays }: { windowDays: number }) { login={r.login} mergedCount={r.mergedCount} reviewedCount={r.reviewedCount} - windowHours={Math.round(r.windowCostUsd / assumptions.hourlyRateUsd)} - windowCostUsd={r.windowCostUsd} - annualCostUsd={r.annualCostUsd} + value={ + displayMode === 'time' + ? Math.round( + actionsToHours(r.mergedCount + r.reviewedCount, assumptions.minutesPerPr), + ).toLocaleString() + : fmtUsd(r.windowCostUsd) + } /> ))} {hiddenCount > 0 || expanded ? ( - +