diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts
index 1f6303d..714a902 100644
--- a/src/report/aggregate.test.ts
+++ b/src/report/aggregate.test.ts
@@ -35,11 +35,27 @@ test('counts merged-in-window PRs and surfaces backlog age buckets', () => {
mergedInWindowCount: 1,
oldestOpenDays: expect.any(Number) as number,
});
- // The 263-day-old PR should fall into the 180+ bucket.
- const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days');
+ // The 263-day-old PR should fall into the 90+ bucket (90 and 180+ are no longer split).
+ const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '90+ days');
expect(oldBucket?.count).toBe(1);
});
+test('averages open PR age and reports null when nothing is open', () => {
+ // collectionContext.now is 2026-05-22, so these open PRs are 10 and 30 days old.
+ const withOpen = collectedData.build({
+ dependabotPrs: [
+ dependabotPr.build({ state: 'open', createdAt: '2026-05-12T00:00:00Z' }),
+ dependabotPr.build({ state: 'open', createdAt: '2026-04-22T00:00:00Z' }),
+ ],
+ });
+ expect(aggregate(withOpen).prBacklog.openAvgAgeDays).toBe(20);
+
+ const noOpen = collectedData.build({
+ dependabotPrs: [dependabotPr.build({ state: 'closed', merged: true, mergedAt: '2026-04-01T00:00:00Z' })],
+ });
+ expect(aggregate(noOpen).prBacklog.openAvgAgeDays).toBeNull();
+});
+
test('rolls org/visibility/language counts up into orgOverview', () => {
const data = collectedData.build({
repos: [
@@ -180,13 +196,13 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges
const bundle = aggregate(data);
expect(bundle.costEstimate.humanMergeCount).toBe(100);
expect(bundle.costEstimate.humanReviewCount).toBe(0);
- expect(bundle.costEstimate.hourlyRateUsd).toBe(150);
- expect(bundle.costEstimate.minutesPerPr).toBe(5);
- // 100 actions × 5 min × $150/hr / 60 = $1250 in window
- expect(bundle.costEstimate.windowCostUsd).toBe(1250);
- // ~$423/month over 90 days (window × 30.44/90)
- expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(400);
- expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(450);
+ 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.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[0]?.annualSavingsUsd).toBe(
diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts
index 1c4364c..600f299 100644
--- a/src/report/aggregate.ts
+++ b/src/report/aggregate.ts
@@ -60,6 +60,7 @@ export interface PrBacklog {
mergedInWindowCount: number;
openAgeBuckets: Array<{ label: string; count: number }>;
oldestOpenDays: number | null;
+ openAvgAgeDays: number | null;
bumpTypeSplit: Array<{ bumpType: string; count: number; percentage: number }>;
devOnlyShare: { count: number; percentage: number };
ciStatusMix: { green: number; failing: number; pending: number };
@@ -235,19 +236,17 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant)
{ label: '0–30 days', min: 0, max: 30 },
{ label: '30–60 days', min: 30, max: 60 },
{ label: '60–90 days', min: 60, max: 90 },
- { label: '90–180 days', min: 90, max: 180 },
- { label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY },
+ { label: '90+ days', min: 90, max: Number.POSITIVE_INFINITY },
];
+ const openAges = openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt)));
const openAgeBuckets = buckets.map((b) => ({
label: b.label,
- count: openPrs.filter((p) => {
- const age = daysBetween(now, instantFromString(p.createdAt));
- return age >= b.min && age < b.max;
- }).length,
+ count: openAges.filter((age) => age >= b.min && age < b.max).length,
}));
- const oldestOpenDays =
- openPrs.length === 0 ? null : Math.max(...openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt))));
+ const oldestOpenDays = openAges.length === 0 ? null : Math.max(...openAges);
+ const openAvgAgeDays =
+ openAges.length === 0 ? null : Math.round(openAges.reduce((sum, age) => sum + age, 0) / openAges.length);
const bumpCounts = new Map();
for (const pr of prs) {
@@ -300,6 +299,7 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant)
mergedInWindowCount: mergedInWindow.length,
openAgeBuckets,
oldestOpenDays,
+ openAvgAgeDays,
bumpTypeSplit,
devOnlyShare,
ciStatusMix: { green, failing, pending },
diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts
index b9d4530..f2590a6 100644
--- a/src/report/costFormulas.ts
+++ b/src/report/costFormulas.ts
@@ -2,8 +2,8 @@
// React app. No Temporal, no Bun globals, no React — keep this module dependency-free
// so it bundles cleanly into both targets.
-export const ASSUMED_HOURLY_RATE_USD = 150;
-export const ASSUMED_MIN_PER_PR = 5;
+export const ASSUMED_HOURLY_RATE_USD = 200;
+export const ASSUMED_MIN_PER_PR = 12;
export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const;
diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts
index 5394c07..c4b4a25 100644
--- a/src/report/testFactories.ts
+++ b/src/report/testFactories.ts
@@ -11,6 +11,7 @@ import type {
ReportMeta,
StalledSignals,
} from './aggregate.ts';
+import { ASSUMED_HOURLY_RATE_USD, ASSUMED_MIN_PER_PR, deriveCostEstimate, derivePersonCosts } from './costFormulas.ts';
import { type EmbeddedReportData, toEmbeddedShape } from './embeddedShape.ts';
import type { ReportAnalyticsConfig } from './reportAnalyticsConfig.ts';
@@ -56,10 +57,10 @@ export const prBacklog = Factory.define(() => ({
{ label: '0–30 days', count: 40 },
{ label: '30–60 days', count: 18 },
{ label: '60–90 days', count: 6 },
- { label: '90–180 days', count: 25 },
- { label: '180+ days', count: 13 },
+ { label: '90+ days', count: 38 },
],
oldestOpenDays: 312,
+ openAvgAgeDays: 74,
bumpTypeSplit: [
{ bumpType: 'patch', count: 150, percentage: 55 },
{ bumpType: 'minor', count: 95, percentage: 34.8 },
@@ -77,31 +78,42 @@ export const stalledSignals = Factory.define(() => ({
reposWithConfigButNoRecentPrs: ['acme/old-tool'],
}));
+// 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_REVIEW_COUNT = 12;
+
export const people = Factory.define(() => ({
- mergers: [
- { login: 'alice', count: 90, windowCostUsd: 1125, annualCostUsd: 4563 },
- { login: 'bob', count: 60, windowCostUsd: 750, annualCostUsd: 3042 },
- ],
- reviewers: [{ login: 'alice', count: 12, windowCostUsd: 90, annualCostUsd: 365 }],
+ mergers: derivePersonCosts(
+ [
+ { login: 'alice', count: 90 },
+ { login: 'bob', count: 60 },
+ ],
+ COST_WINDOW_DAYS,
+ ASSUMED_MIN_PER_PR,
+ ASSUMED_HOURLY_RATE_USD,
+ ),
+ reviewers: derivePersonCosts(
+ [{ login: 'alice', count: 12 }],
+ COST_WINDOW_DAYS,
+ ASSUMED_MIN_PER_PR,
+ ASSUMED_HOURLY_RATE_USD,
+ ),
commenters: [],
}));
export const costEstimate = Factory.define(() => ({
- humanMergeCount: 150,
- humanReviewCount: 12,
+ humanMergeCount: HUMAN_MERGE_COUNT,
+ humanReviewCount: HUMAN_REVIEW_COUNT,
openCount: 102,
- windowDays: 90,
- hourlyRateUsd: 150,
- minutesPerPr: 5,
- windowCostUsd: 2025,
- monthlyCostUsd: 684,
- annualCostUsd: 8208,
- savingsScenarios: [
- { autoMergeRate: 0.5, monthlySavingsUsd: 342, annualSavingsUsd: 4104 },
- { autoMergeRate: 0.6, monthlySavingsUsd: 410, annualSavingsUsd: 4920 },
- { autoMergeRate: 0.7, monthlySavingsUsd: 479, annualSavingsUsd: 5748 },
- { autoMergeRate: 0.8, monthlySavingsUsd: 547, annualSavingsUsd: 6564 },
- ],
+ windowDays: COST_WINDOW_DAYS,
+ hourlyRateUsd: ASSUMED_HOURLY_RATE_USD,
+ minutesPerPr: ASSUMED_MIN_PER_PR,
+ ...deriveCostEstimate(HUMAN_MERGE_COUNT + HUMAN_REVIEW_COUNT, COST_WINDOW_DAYS, {
+ hourlyRateUsd: ASSUMED_HOURLY_RATE_USD,
+ minutesPerPr: ASSUMED_MIN_PER_PR,
+ }),
}));
export const cveExposureOk = Factory.define(() => ({
diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx
index 0c09d8a..ce10e7d 100644
--- a/src/report/web/App.browser.test.tsx
+++ b/src/report/web/App.browser.test.tsx
@@ -12,7 +12,6 @@ import { verdictCopy, verdictTestIds } from './acts/Verdict.tsx';
import { AnalyticsProvider } from './analytics/AnalyticsContext.tsx';
import { App, appTestIds } from './App.tsx';
import { assumptionInputTestIds } from './primitives/AssumptionInput.tsx';
-import { assumptionsFootnoteTestId } from './primitives/AssumptionsFootnote.tsx';
import { footnoteReferenceTestId } from './primitives/FootnoteReference.tsx';
import type { EmbeddedReportData } from './types.ts';
@@ -24,9 +23,11 @@ describe('App report shell', () => {
it('renders the headline annual cost from the embedded data', () => {
renderReport();
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$8,208/year');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$26,280/year');
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');
});
it('recalculates the headline cost and comparison cards when assumptions change', () => {
@@ -37,11 +38,11 @@ describe('App report shell', () => {
target: { value: '300' },
});
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$16,428/year');
- expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$16,428/yr');
+ 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('$16,428/yr');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$10,678/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$39,420/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$25,623/yr');
});
it('allows replacing an assumption value by clearing and typing', () => {
@@ -57,7 +58,7 @@ describe('App report shell', () => {
fireEvent.blur(hourlyRateInput);
expect(hourlyRateInput).toHaveValue('275');
- expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$15,060/year');
+ expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$36,132/year');
});
it('recalculates the PatchWave savings card when the auto-merge share changes', () => {
@@ -65,41 +66,12 @@ describe('App report shell', () => {
// Default 65% share starts in the middle of the modeled range.
expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$5,335/yr');
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,082/yr');
fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } });
expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%');
- expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$4,104/yr');
- });
-
- it('reveals the methodology assumptions panel when an estimate footnote is clicked', () => {
- renderReport();
- const details = screen.getByTestId(assumptionInputTestIds.container).closest('details');
- expect(details).toBeTruthy();
- expect(details).not.toHaveAttribute('open');
-
- const footnote = screen.getAllByTestId(assumptionsFootnoteTestId)[0];
- if (!footnote) throw new Error('missing assumptions footnote');
- const restore = suppressNavigation();
- fireEvent.click(footnote);
- restore();
-
- expect(details).toHaveAttribute('open');
- });
-
- it('switches back to the calculation tab when an assumptions footnote is clicked from raw data', () => {
- renderReport();
- fireEvent.click(screen.getByText('How this report was calculated'));
- fireEvent.click(screen.getByRole('tab', { name: 'Raw data' }));
- expect(screen.queryByTestId(assumptionInputTestIds.container)).not.toBeInTheDocument();
-
- const restore = suppressNavigation();
- fireEvent.click(screen.getAllByTestId(assumptionsFootnoteTestId)[0] as HTMLElement);
- restore();
-
- expect(screen.getByRole('tab', { name: 'Calculation' })).toHaveAttribute('aria-selected', 'true');
- expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument();
+ expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$13,140/yr');
});
it('lists footnotes in ascending first-appearance order', () => {
@@ -108,17 +80,17 @@ describe('App report shell', () => {
fireEvent.click(screen.getByText('How this report was calculated'));
const sources = screen.getByTestId(methodologyAppendixTestIds.sources);
- expect(sources).toHaveTextContent('1. Adjustable cost assumptions.');
+ // The solution section leads the report, so its Mohayeji citation is the first footnote.
+ expect(sources).toHaveTextContent('1. Mohayeji et al. 2025');
expect(sources).toHaveTextContent('2. VulnCheck, May 2026');
expect(sources).toHaveTextContent('3. Anthropic, "Project Glasswing');
expect(sources).toHaveTextContent('4. Anthropic, Coordinated Vulnerability Disclosure dashboard');
- expect(sources).toHaveTextContent('5. Mohayeji et al. 2025');
- expect(sources).toHaveTextContent('6. Atlassian State of Developer Experience Report 2025.');
+ expect(sources).toHaveTextContent('5. Atlassian State of Developer Experience Report 2025.');
});
it('opens the appendix source note instead of navigating directly when a citation is clicked', () => {
renderReport();
- const details = screen.getByTestId(assumptionInputTestIds.container).closest('details');
+ const details = screen.getByTestId(methodologyAppendixTestIds.section).querySelector('details');
expect(details).toBeTruthy();
expect(details).not.toHaveAttribute('open');
@@ -136,7 +108,6 @@ describe('App report shell', () => {
it('renders the ok CVE state with severity counts', () => {
renderReport();
- expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent('7 open security alerts');
expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent('7 open security alerts');
expect(screen.getByTestId(riskStoryTestIds.severityBar)).toBeInTheDocument();
});
@@ -196,7 +167,6 @@ describe('App report shell', () => {
},
});
- expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent(verdictCopy.cveScopeMissing);
expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent(riskStoryCopy.scopeMissingHeading);
expect(screen.getByTestId(riskStoryTestIds.scopeRefreshCommand)).toHaveTextContent(
'gh auth refresh -s security_events',
@@ -213,8 +183,6 @@ 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.section)).toHaveTextContent('based on adjustable1 assumptions');
- expect(screen.getByTestId(automatedStoryTestIds.todayCost).parentElement).toHaveTextContent('Adjustable1 estimate');
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta);
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default');
expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel);
@@ -230,26 +198,19 @@ describe('App report shell', () => {
expect(screen.getByTestId(methodologyAppendixTestIds.section)).not.toHaveTextContent('patchwave.ai');
});
- it('places every assumptions footnote immediately after adjustable', () => {
- renderReport();
- fireEvent.click(screen.getByText('How this report was calculated'));
-
- for (const footnote of screen.getAllByTestId(assumptionsFootnoteTestId)) {
- const previousText = footnote.previousSibling?.textContent ?? '';
- expect(previousText.trimEnd().toLowerCase().endsWith('adjustable')).toBe(true);
- }
- });
-
it('combines person merge and review rows and labels the cost window', () => {
renderReport();
const table = screen.getByTestId(costStoryTestIds.peopleTable);
expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument();
- expect(within(table).getAllByText('alice')).toHaveLength(1);
- expect(within(table).getByText('90')).toBeInTheDocument();
- expect(within(table).getAllByText('merged').length).toBeGreaterThan(0);
- expect(within(table).getByText('12')).toBeInTheDocument();
- expect(within(table).getByText('reviewed')).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('merged');
+ expect(aliceRow).toHaveTextContent('12');
+ expect(aliceRow).toHaveTextContent('reviewed');
});
it('limits the people table to the top five with an optional expansion', () => {
@@ -285,16 +246,17 @@ describe('App report shell', () => {
});
const table = screen.getByTestId(costStoryTestIds.peopleTable);
- // 10 reviews x 5 min x $150/hr / 60 = $125 in window at the defaults.
- expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$125');
+ // 10 reviews x 12 min x $200/hr / 60 = $400 in window at the defaults.
+ expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$400');
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
target: { value: '10' },
});
- // Reviews ride the same minutes-per-PR slider as merges, so doubling it doubles the cost.
- expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$250');
+ // 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');
});
it('reworks the raw-data per-person costs when an assumption changes', () => {
@@ -306,7 +268,7 @@ describe('App report shell', () => {
},
});
- // The input lives on the Calculation tab and unmounts on Raw data, so adjust before navigating.
+ // 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' },
@@ -315,9 +277,9 @@ describe('App report shell', () => {
fireEvent.click(screen.getByText('How this report was calculated'));
fireEvent.click(screen.getByRole('tab', { name: 'Raw data' }));
- // 10 reviews x 10 min x $150/hr / 60 = $250 window, annualized to $1,014/yr.
+ // 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,014/yr');
+ expect(within(rawData).getByText('carol').closest('li')).toHaveTextContent('$1,351/yr');
});
it('renders the open PR age buckets as a separate section with count-only rows', () => {
@@ -326,6 +288,11 @@ describe('App report shell', () => {
const section = screen.getByTestId(openPrAgeStoryTestIds.section);
const breakdown = screen.getByTestId(openPrAgeStoryTestIds.breakdown);
expect(section).toHaveTextContent(openPrAgeStoryCopy.heading);
+ // Headline backlog stats summarize the section before the per-bucket bars.
+ expect(section).toHaveTextContent('102');
+ expect(section).toHaveTextContent('still open');
+ expect(section).toHaveTextContent('74 days');
+ expect(section).toHaveTextContent('average age');
expect(breakdown).toHaveTextContent('0–30 days');
expect(breakdown).toHaveTextContent('40');
expect(breakdown).toHaveTextContent('Time-to-merge in your data: p50 2d, p90 14d');
@@ -339,6 +306,7 @@ describe('App report shell', () => {
...embeddedReportData.build().prBacklog,
openCount: 0,
oldestOpenDays: null,
+ openAvgAgeDays: null,
openAgeBuckets: [],
},
});
@@ -347,6 +315,7 @@ describe('App report shell', () => {
expect(section).toHaveTextContent(openPrAgeStoryCopy.emptyHeading);
expect(section).not.toHaveTextContent(openPrAgeStoryCopy.heading);
expect(section).not.toHaveTextContent('Volume is trending up, not down');
+ expect(section).not.toHaveTextContent('average age');
});
});
diff --git a/src/report/web/App.stories.tsx b/src/report/web/App.stories.tsx
index 19b0d83..277b1a8 100644
--- a/src/report/web/App.stories.tsx
+++ b/src/report/web/App.stories.tsx
@@ -7,6 +7,27 @@ import { App } from './App.tsx';
// Defaults cover most of the page; the overrides here only fill the spots that
// would otherwise render as single-row tables (language mix, top repos by
// severity) so the snapshot exercises the full UI.
+//
+// The global severity totals are summed from the per-repo rows so the
+// distribution bar and the per-repo breakdown always reconcile, mirroring how
+// production derives both from the same alert list.
+const topReposBySeverity = [
+ { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 },
+ { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 },
+ { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 },
+ { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 },
+ { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 },
+ { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 },
+ { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 },
+ { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 },
+];
+const bySeverity = {
+ critical: sumBy(topReposBySeverity, 'critical'),
+ high: sumBy(topReposBySeverity, 'high'),
+ medium: sumBy(topReposBySeverity, 'medium'),
+ low: sumBy(topReposBySeverity, 'low'),
+};
+
const sampleReport = toEmbeddedShape(
reportBundle.build({
orgOverview: orgOverview.build({
@@ -18,16 +39,9 @@ const sampleReport = toEmbeddedShape(
],
}),
cve: cveExposureOk.build({
- topReposBySeverity: [
- { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 },
- { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 },
- { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 },
- { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 },
- { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 },
- { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 },
- { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 },
- { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 },
- ],
+ totalOpenAlerts: bySeverity.critical + bySeverity.high + bySeverity.medium + bySeverity.low,
+ bySeverity,
+ topReposBySeverity,
reposWithSecurityAlertsDisabled: ['acme/legacy-cron'],
}),
people: people.build({
@@ -86,3 +100,7 @@ export const CveScopeMissing: Story = {
data: { ...sampleReport, cve: cveExposureScopeMissing.build() },
},
};
+
+function sumBy(rows: readonly T[], key: keyof T): number {
+ return rows.reduce((total, row) => total + (row[key] as number), 0);
+}
diff --git a/src/report/web/App.tsx b/src/report/web/App.tsx
index c418b56..7b3b55e 100644
--- a/src/report/web/App.tsx
+++ b/src/report/web/App.tsx
@@ -31,10 +31,10 @@ export function App({ data }: { data: EmbeddedReportData }) {
+
-
diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx
index ea967e5..bf8fd2f 100644
--- a/src/report/web/acts/AutomatedStory.tsx
+++ b/src/report/web/acts/AutomatedStory.tsx
@@ -1,8 +1,10 @@
import { useState } from 'react';
+import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
+import { Button } from '../components/ui/button.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
-import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
import { Citation } from '../primitives/Citation.tsx';
+import { callToActionCopy } from './CallToAction.tsx';
export const automatedStoryTestIds = {
section: 'automated-story-section',
@@ -10,14 +12,22 @@ export const automatedStoryTestIds = {
patchwaveCost: 'automated-story-patchwave-cost',
delta: 'automated-story-delta',
shareSlider: 'automated-story-share-slider',
+ waitlistCta: 'automated-story-waitlist-cta',
} as const;
const SHARE_MIN = 50;
const SHARE_MAX = 80;
+const SHARE_STEP = 5;
const SHARE_DEFAULT = 65;
+const SHARE_MID = (SHARE_MIN + SHARE_MAX) / 2;
+const SHARE_STOPS = Array.from(
+ { length: (SHARE_MAX - SHARE_MIN) / SHARE_STEP + 1 },
+ (_, i) => SHARE_MIN + i * SHARE_STEP,
+);
export function AutomatedStory() {
const { assumptions, derived } = useAssumptions();
+ const analytics = useAnalytics();
const [sharePct, setSharePct] = useState(SHARE_DEFAULT);
const todayCost = derived.annualCostUsd;
@@ -43,7 +53,6 @@ export function AutomatedStory() {
label="Today"
value={`${fmtUsd(todayCost)}/yr`}
sub={quarterHoursLabel(todayCost, assumptions.hourlyRateUsd)}
- footnote
/>
- cost recovered
+ PRs auto-merged
setSharePct(Number(e.target.value))}
- className="accent-primary mt-3 w-full"
+ className="accent-primary mt-3 block w-full"
/>
+
+ {SHARE_STOPS.map((stop) => (
+
+ ))}
+
{SHARE_MIN}%
+ {SHARE_MID}%
{SHARE_MAX}%
@@ -92,6 +110,16 @@ export function AutomatedStory() {
wrong
.
+
+
+ analytics.capture('cta_clicked', { which: 'automated_story_waitlist' })}
+ >
+ {callToActionCopy.ctaLabel}
+
+
);
}
@@ -102,34 +130,23 @@ function CompareCard({
sub,
testId,
accent = false,
- footnote = false,
}: {
label: string;
value: string;
sub: string;
testId: string;
accent?: boolean;
- footnote?: boolean;
}) {
return (
{label}
{value}
-
- {footnote && (
- <>
- Adjustable
-
estimate
-
·
- >
- )}
- {sub}
-
+
{sub}
);
}
diff --git a/src/report/web/acts/CallToAction.tsx b/src/report/web/acts/CallToAction.tsx
index adc2f82..372dd64 100644
--- a/src/report/web/acts/CallToAction.tsx
+++ b/src/report/web/acts/CallToAction.tsx
@@ -11,7 +11,7 @@ export const callToActionCopy = {
pitch:
'PatchWave reviews each Dependabot PR, auto-merges the safe updates, and gives engineers context for the few that need judgment.',
earlyAccess: "Early access, plus a heads-up when the public beta opens. That's all we'll email you about.",
- ctaLabel: 'Join the waitlist →',
+ ctaLabel: 'Join the waitlist',
} as const;
export function CallToAction() {
diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx
index 9826c01..0acc250 100644
--- a/src/report/web/acts/CostStory.tsx
+++ b/src/report/web/acts/CostStory.tsx
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
-import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
import { PersonRow } from '../primitives/PersonRow.tsx';
export const costStoryTestIds = {
@@ -39,8 +38,7 @@ 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 adjustable
- defaults of{' '}
+ Anything a bot auto-merged is left out. At{' '}
{assumptions.minutesPerPr} minutes per PR and{' '}
${assumptions.hourlyRateUsd}/hr , that comes out to:
@@ -67,7 +65,8 @@ export function CostStory() {
- The 5 min/PR default is deliberately low, so these totals lean conservative.
+ The 12 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.
);
@@ -95,7 +94,7 @@ function CostCell({
}
function PeopleTable({ windowDays }: { windowDays: number }) {
- const { derived } = useAssumptions();
+ const { assumptions, derived } = useAssumptions();
const [expanded, setExpanded] = useState(false);
const people = combinedPeopleRows(derived.mergers, derived.reviewers);
const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT);
@@ -120,6 +119,7 @@ function PeopleTable({ windowDays }: { windowDays: number }) {
Person
Count
+ Time (hrs)
Cost over last {windowDays} days
Annualized
@@ -131,13 +131,14 @@ 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}
/>
))}
{hiddenCount > 0 || expanded ? (
-
+
{activeTab === 'calculation' && (
-
+
Cost assumptions
- Every cost and time figure in this report is modeled from these two inputs. Change them and the
- numbers throughout recalculate. The defaults are intentionally conservative.
+ Every cost and time figure in this report is modeled from two inputs: a loaded hourly rate and the
+ minutes spent per PR. Adjust them at the top of the report and the numbers throughout recalculate.
+
+
+ Currently ${assumptions.hourlyRateUsd}/hr and{' '}
+ {assumptions.minutesPerPr} min per PR.
-
@@ -99,14 +98,17 @@ export function MethodologyAppendix() {
value={
<>
(Human merges + reviews) × minutes per PR ÷ 60 × hourly rate.
- Bot-merged PRs are excluded. Currently using adjustable
- assumptions:{' '}
+ Bot-merged PRs are excluded. Currently using adjustable assumptions:{' '}
{data.costEstimate.humanMergeCount + data.costEstimate.humanReviewCount} actions ×{' '}
{assumptions.minutesPerPr} min ÷ 60 × ${assumptions.hourlyRateUsd}
/hr.
>
}
/>
+
- The adjustable
- 5 min/PR default is deliberately low. The
- adjustable
- $150/hr default reflects a $300k engineer
- cost divided by 2,000 working hours.
+ The adjustable 12 min/PR default covers the context switch, review, and merge for a single PR. The
+ adjustable $200/hr default reflects a $400k fully-loaded engineer cost divided by 2,000 working
+ hours.
Estimates only; real savings vary by team. The defaults are intentionally conservative
diff --git a/src/report/web/acts/OpenPrAgeStory.tsx b/src/report/web/acts/OpenPrAgeStory.tsx
index 1a4de77..df9f65d 100644
--- a/src/report/web/acts/OpenPrAgeStory.tsx
+++ b/src/report/web/acts/OpenPrAgeStory.tsx
@@ -40,6 +40,17 @@ export function OpenPrAgeStory() {
)}
+ {hasOpenPrs && pr.openAvgAgeDays !== null && (
+
+
+
+
+ )}
+
{!hasOpenPrs ? (
@@ -63,6 +74,15 @@ export function OpenPrAgeStory() {
);
}
+function BacklogStat({ value, label, className }: { value: string; label: string; className?: string }) {
+ return (
+
+ );
+}
+
function AgeBucketRow({ label, count, total }: { label: string; count: number; total: number }) {
const pct = total === 0 ? 0 : Math.round((count / total) * 100);
return (
diff --git a/src/report/web/acts/RiskStory.tsx b/src/report/web/acts/RiskStory.tsx
index faa2b7d..3886cf3 100644
--- a/src/report/web/acts/RiskStory.tsx
+++ b/src/report/web/acts/RiskStory.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { Citation } from '../primitives/Citation.tsx';
import { FootnoteReference } from '../primitives/FootnoteReference.tsx';
-import { StackedBar } from '../primitives/StackedBar.tsx';
+import { SEGMENTS, StackedBar } from '../primitives/StackedBar.tsx';
export const riskStoryTestIds = {
section: 'risk-story-section',
@@ -122,7 +122,7 @@ export function RiskStory() {
Top repos by severity
-
+
>
)}
@@ -158,62 +158,73 @@ interface RepoSeverityRow {
low: number;
}
-function TopReposBySeverityTable({ repos }: { repos: readonly RepoSeverityRow[] }) {
+// Bars scale to the busiest repo so length reads as volume; the distribution
+// bar above already carries the legend, so these rows omit it.
+function TopReposBySeverityBars({ repos }: { repos: readonly RepoSeverityRow[] }) {
const [expanded, setExpanded] = useState(false);
const visibleRepos = expanded ? repos : repos.slice(0, INITIAL_REPO_COUNT);
const hiddenCount = repos.length - visibleRepos.length;
+ const maxTotal = Math.max(1, ...repos.map(repoTotal));
return (
-
-
-
-
- Repo
- Critical
- High
- Medium
- Low
-
-
-
- {visibleRepos.map((r) => (
-
- {r.repo}
- {r.critical || ''}
- {r.high || ''}
- {r.medium || ''}
- {r.low || ''}
-
- ))}
- {hiddenCount > 0 || expanded ? (
-
-
- setExpanded((open) => !open)}
- className="text-muted-foreground hover:text-foreground text-sm font-medium underline-offset-4 hover:underline"
- >
- {expanded ? 'Show top 5' : `Show ${hiddenCount.toLocaleString()} more`}
-
-
-
- ) : null}
-
-
+
+ {visibleRepos.map((r) => (
+
+ ))}
+ {hiddenCount > 0 || expanded ? (
+ setExpanded((open) => !open)}
+ className="text-muted-foreground hover:text-foreground pt-1 text-sm font-medium underline-offset-4 hover:underline"
+ >
+ {expanded ? 'Show top 5' : `Show ${hiddenCount.toLocaleString()} more`}
+
+ ) : null}
+
+ );
+}
+
+function RepoSeverityBar({ repo, maxTotal }: { repo: RepoSeverityRow; maxTotal: number }) {
+ const total = repoTotal(repo);
+ const counts = { critical: repo.critical, high: repo.high, medium: repo.medium, low: repo.low };
+ return (
+
+
+ {repo.repo}
+
+
+
+ {SEGMENTS.map((s) => {
+ const v = counts[s.key];
+ if (v === 0) return null;
+ return (
+
+ );
+ })}
+
+
+
{total.toLocaleString()}
);
}
+function repoTotal(r: RepoSeverityRow): number {
+ return r.critical + r.high + r.medium + r.low;
+}
+
function AgeCell({ label, days, tone }: { label: string; days: number; tone: 'critical' | 'high' }) {
- const color = tone === 'critical' ? 'text-destructive' : 'text-tangerine';
+ const color = tone === 'critical' ? 'var(--severity-critical)' : 'var(--severity-high)';
return (
{label}
-
{days} days
+
+ {days} days
+
);
}
diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx
index 169ca83..923f20a 100644
--- a/src/report/web/acts/Verdict.tsx
+++ b/src/report/web/acts/Verdict.tsx
@@ -3,12 +3,11 @@ 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 { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
+import { HeroAssumptions } from '../primitives/HeroAssumptions.tsx';
export const verdictTestIds = {
section: 'verdict-section',
annualCost: 'verdict-annual-cost',
- cveLine: 'verdict-cve-line',
primaryCta: 'verdict-primary-cta',
} as const;
@@ -16,11 +15,11 @@ export const verdictCopy = {
costLeadIn: 'Your engineering team spends',
costTrailer: 'triaging, reviewing, and merging Dependabot PRs',
primaryCta: 'See how PatchWave helps',
- cveScopeMissing: 'CVE exposure not measured (missing GitHub scope)',
} as const;
export function Verdict() {
const { derived } = useAssumptions();
+ const { openCount } = useEmbeddedData().prBacklog;
const analytics = useAnalytics();
return (
@@ -34,12 +33,12 @@ export function Verdict() {
/year
- {verdictCopy.costTrailer}, based on adjustable
- assumptions
+ {verdictCopy.costTrailer}
+ {openCount > 0 && (
+ (not including the {openCount.toLocaleString()} still open)
+ )}
-
-
-
- );
-}
-// One dense line under the headline: volume and time first, the alert count as a
-// tail signal. The CVE fragment also carries the not-measured state so the
-// report never silently drops the security signal.
-function SupportingFacts() {
- const { prBacklog: pr } = useEmbeddedData();
- const oldestSuffix = pr.oldestOpenDays !== null ? ` (oldest ${pr.oldestOpenDays} days)` : '';
-
- return (
-
-
- {pr.mergedInWindowCount.toLocaleString()} Dependabot PRs
- merged
-
-
-
- {pr.openCount.toLocaleString()} still open{oldestSuffix}
-
-
-
-
- );
-}
-
-function CveFact() {
- const cve = useEmbeddedData().cve;
- if (cve.status === 'scope-missing') {
- return (
-
- {verdictCopy.cveScopeMissing}
-
- );
- }
- return (
-
- {cve.totalOpenAlerts.toLocaleString()} open security alerts
-
- );
-}
-
-function Dot() {
- return (
-
- ·
-
+
+
+
+
);
}
diff --git a/src/report/web/hooks/useAssumptionsDisclosure.tsx b/src/report/web/hooks/useAssumptionsDisclosure.tsx
index 9bb3e40..64017ff 100644
--- a/src/report/web/hooks/useAssumptionsDisclosure.tsx
+++ b/src/report/web/hooks/useAssumptionsDisclosure.tsx
@@ -28,9 +28,6 @@ export function AssumptionsDisclosureProvider({ children }: { children: ReactNod
const revealHashTarget = () => {
if (window.location.hash.length <= 1) return;
const target = decodeURIComponent(window.location.hash.slice(1));
- if (target === 'appendix-assumptions') {
- reveal('calculation');
- }
if (target === 'appendix-sources' || target.startsWith('footnote-')) {
setOpen(true);
}
diff --git a/src/report/web/primitives/AssumptionsFootnote.tsx b/src/report/web/primitives/AssumptionsFootnote.tsx
deleted file mode 100644
index 7576e5a..0000000
--- a/src/report/web/primitives/AssumptionsFootnote.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
-import { FootnoteReference } from './FootnoteReference.tsx';
-
-export const assumptionsFootnoteTestId = 'assumptions-footnote';
-export const assumptionsFootnoteId = 'assumptions';
-export const assumptionsPanelId = 'appendix-assumptions';
-
-export function AssumptionsFootnote({ from, className }: { from: string; className?: string }) {
- const analytics = useAnalytics();
- return (
-